258
common/resources/client/ui/accordion/accordion.tsx
Executable file
258
common/resources/client/ui/accordion/accordion.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
19
common/resources/client/ui/accordion/accordtion-animation.ts
Executable file
19
common/resources/client/ui/accordion/accordtion-animation.ts
Executable 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;
|
||||
8
common/resources/client/ui/animation/opacity-animation.ts
Executable file
8
common/resources/client/ui/animation/opacity-animation.ts
Executable 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},
|
||||
};
|
||||
32
common/resources/client/ui/badge/badge.tsx
Executable file
32
common/resources/client/ui/badge/badge.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
78
common/resources/client/ui/breadcrumbs/breadcrumb-item.tsx
Executable file
78
common/resources/client/ui/breadcrumbs/breadcrumb-item.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
231
common/resources/client/ui/breadcrumbs/breadcrumb.tsx
Executable file
231
common/resources/client/ui/breadcrumbs/breadcrumb.tsx
Executable 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;
|
||||
}
|
||||
24
common/resources/client/ui/bullet-seprated-items.tsx
Executable file
24
common/resources/client/ui/bullet-seprated-items.tsx
Executable 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>•</div> : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
common/resources/client/ui/buttons/button-base.tsx
Executable file
96
common/resources/client/ui/buttons/button-base.tsx
Executable 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>
|
||||
);
|
||||
});
|
||||
111
common/resources/client/ui/buttons/button-group.tsx
Executable file
111
common/resources/client/ui/buttons/button-group.tsx
Executable 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',
|
||||
);
|
||||
}
|
||||
37
common/resources/client/ui/buttons/button-size.ts
Executable file
37
common/resources/client/ui/buttons/button-size.ts
Executable 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 || '';
|
||||
}
|
||||
}
|
||||
78
common/resources/client/ui/buttons/button.tsx
Executable file
78
common/resources/client/ui/buttons/button.tsx
Executable 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});
|
||||
}
|
||||
18
common/resources/client/ui/buttons/external-link.tsx
Executable file
18
common/resources/client/ui/buttons/external-link.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
172
common/resources/client/ui/buttons/get-shared-button-style.ts
Executable file
172
common/resources/client/ui/buttons/get-shared-button-style.ts
Executable 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];
|
||||
}
|
||||
}
|
||||
51
common/resources/client/ui/buttons/icon-button.tsx
Executable file
51
common/resources/client/ui/buttons/icon-button.tsx
Executable 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
46
common/resources/client/ui/color-picker/color-picker-dialog.tsx
Executable file
46
common/resources/client/ui/color-picker/color-picker-dialog.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
74
common/resources/client/ui/color-picker/color-picker.tsx
Executable file
74
common/resources/client/ui/color-picker/color-picker.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
87
common/resources/client/ui/color-picker/color-presets.ts
Executable file
87
common/resources/client/ui/color-picker/color-presets.ts
Executable 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)',
|
||||
},
|
||||
];
|
||||
33
common/resources/client/ui/color-picker/color-swatch.tsx
Executable file
33
common/resources/client/ui/color-picker/color-swatch.tsx
Executable 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>;
|
||||
}
|
||||
32
common/resources/client/ui/cookie-notice/cookie-law-countries.ts
Executable file
32
common/resources/client/ui/cookie-notice/cookie-law-countries.ts
Executable 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',
|
||||
];
|
||||
68
common/resources/client/ui/cookie-notice/cookie-notice.tsx
Executable file
68
common/resources/client/ui/cookie-notice/cookie-notice.tsx
Executable 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
35
common/resources/client/ui/cookie-notice/use-user-is-from-eu.ts
Executable file
35
common/resources/client/ui/cookie-notice/use-user-is-from-eu.ts
Executable 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};
|
||||
});
|
||||
}
|
||||
35
common/resources/client/ui/dynamic-homepage.tsx
Executable file
35
common/resources/client/ui/dynamic-homepage.tsx
Executable 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;
|
||||
}
|
||||
27
common/resources/client/ui/focus/use-auto-focus.ts
Executable file
27
common/resources/client/ui/focus/use-auto-focus.ts
Executable 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]);
|
||||
}
|
||||
28
common/resources/client/ui/font-picker/browser-safe-fonts.ts
Executable file
28
common/resources/client/ui/font-picker/browser-safe-fonts.ts
Executable 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',
|
||||
},
|
||||
];
|
||||
5
common/resources/client/ui/font-picker/font-face-config.ts
Executable file
5
common/resources/client/ui/font-picker/font-face-config.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
export interface FontFaceConfig {
|
||||
family: string;
|
||||
src: string;
|
||||
descriptors?: FontFaceDescriptors;
|
||||
}
|
||||
75
common/resources/client/ui/font-picker/load-fonts.ts
Executable file
75
common/resources/client/ui/font-picker/load-fonts.ts
Executable 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);
|
||||
}
|
||||
13
common/resources/client/ui/font-picker/load-google-fonts.ts
Executable file
13
common/resources/client/ui/font-picker/load-google-fonts.ts
Executable 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}
|
||||
);
|
||||
}
|
||||
}
|
||||
69
common/resources/client/ui/font-selector/font-selector-filters.tsx
Executable file
69
common/resources/client/ui/font-selector/font-selector-filters.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
50
common/resources/client/ui/font-selector/font-selector-pagination.tsx
Executable file
50
common/resources/client/ui/font-selector/font-selector-pagination.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
83
common/resources/client/ui/font-selector/font-selector-state.ts
Executable file
83
common/resources/client/ui/font-selector/font-selector-state.ts
Executable 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,
|
||||
};
|
||||
}
|
||||
125
common/resources/client/ui/font-selector/font-selector.tsx
Executable file
125
common/resources/client/ui/font-selector/font-selector.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
1
common/resources/client/ui/font-selector/font.svg
Executable file
1
common/resources/client/ui/font-selector/font.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
87
common/resources/client/ui/footer/footer.tsx
Executable file
87
common/resources/client/ui/footer/footer.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
61
common/resources/client/ui/forms/combobox/combobox-end-adornment.tsx
Executable file
61
common/resources/client/ui/forms/combobox/combobox-end-adornment.tsx
Executable file
@@ -0,0 +1,61 @@
|
||||
import React, {ReactElement, useEffect, useRef, useState} from 'react';
|
||||
import {SvgIconProps} from '@common/icons/svg-icon';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {useListboxContext} from '@common/ui/forms/listbox/listbox-context';
|
||||
import {ProgressCircle} from '@common/ui/progress/progress-circle';
|
||||
import {KeyboardArrowDownIcon} from '@common/icons/material/KeyboardArrowDown';
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean;
|
||||
icon?: ReactElement<SvgIconProps>;
|
||||
}
|
||||
export function ComboboxEndAdornment({isLoading, icon}: Props) {
|
||||
const timeout = useRef<any>(null);
|
||||
const {trans} = useTrans();
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
|
||||
const {
|
||||
state: {isOpen, inputValue},
|
||||
} = useListboxContext();
|
||||
|
||||
const lastInputValue = useRef(inputValue);
|
||||
useEffect(() => {
|
||||
if (isLoading && !showLoading) {
|
||||
if (timeout.current === null) {
|
||||
timeout.current = setTimeout(() => {
|
||||
setShowLoading(true);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// If user is typing, clear the timer and restart since it is a new request
|
||||
if (inputValue !== lastInputValue.current) {
|
||||
clearTimeout(timeout.current);
|
||||
timeout.current = setTimeout(() => {
|
||||
setShowLoading(true);
|
||||
}, 500);
|
||||
}
|
||||
} else if (!isLoading) {
|
||||
// If loading is no longer happening, clear any timers and hide the loading circle
|
||||
setShowLoading(false);
|
||||
clearTimeout(timeout.current);
|
||||
timeout.current = null;
|
||||
}
|
||||
|
||||
lastInputValue.current = inputValue;
|
||||
}, [isLoading, showLoading, inputValue]);
|
||||
|
||||
// loading circle should only be displayed if menu is open, if menuTrigger is "manual", or first time load (to stop circle from showing up when user selects an option)
|
||||
const showLoadingIndicator = showLoading && (isOpen || isLoading);
|
||||
|
||||
if (showLoadingIndicator) {
|
||||
return (
|
||||
<ProgressCircle
|
||||
aria-label={trans({message: 'Loading'})}
|
||||
size="sm"
|
||||
isIndeterminate
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return icon || <KeyboardArrowDownIcon />;
|
||||
}
|
||||
183
common/resources/client/ui/forms/combobox/combobox.tsx
Executable file
183
common/resources/client/ui/forms/combobox/combobox.tsx
Executable file
@@ -0,0 +1,183 @@
|
||||
import React, {ReactElement, Ref} from 'react';
|
||||
import {BaseFieldPropsWithDom} from '../input-field/base-field-props';
|
||||
import {Item} from '../listbox/item';
|
||||
import {useListbox} from '../listbox/use-listbox';
|
||||
import {IconButton} from '../../buttons/icon-button';
|
||||
import {TextField} from '../input-field/text-field/text-field';
|
||||
import {Listbox} from '../listbox/listbox';
|
||||
import {SvgIconProps} from '@common/icons/svg-icon';
|
||||
import {useListboxKeyboardNavigation} from '@common/ui/forms/listbox/use-listbox-keyboard-navigation';
|
||||
import {createEventHandler} from '@common/utils/dom/create-event-handler';
|
||||
import {ListBoxChildren, ListboxProps} from '../listbox/types';
|
||||
import {Popover} from '../../overlays/popover';
|
||||
import {ComboboxEndAdornment} from '@common/ui/forms/combobox/combobox-end-adornment';
|
||||
|
||||
export {Item as Option};
|
||||
|
||||
export type ComboboxProps<T extends object> = Omit<
|
||||
BaseFieldPropsWithDom<HTMLInputElement>,
|
||||
'endAdornment'
|
||||
> &
|
||||
ListBoxChildren<T> &
|
||||
ListboxProps & {
|
||||
selectionMode?: 'single' | 'none';
|
||||
isAsync?: boolean;
|
||||
isLoading?: boolean;
|
||||
openMenuOnFocus?: boolean;
|
||||
endAdornmentIcon?: ReactElement<SvgIconProps>;
|
||||
useOptionLabelAsInputValue?: boolean;
|
||||
hideEndAdornment?: boolean;
|
||||
onEndAdornmentClick?: () => void;
|
||||
prependListbox?: boolean;
|
||||
listboxClassName?: string;
|
||||
};
|
||||
|
||||
function ComboBox<T extends object>(
|
||||
props: ComboboxProps<T> & {selectionMode: 'single'},
|
||||
ref: Ref<HTMLInputElement>,
|
||||
) {
|
||||
const {
|
||||
children,
|
||||
items,
|
||||
isAsync,
|
||||
isLoading,
|
||||
openMenuOnFocus = true,
|
||||
endAdornmentIcon,
|
||||
onItemSelected,
|
||||
maxItems,
|
||||
clearInputOnItemSelection,
|
||||
inputValue: userInputValue,
|
||||
selectedValue,
|
||||
onSelectionChange,
|
||||
allowCustomValue = false,
|
||||
onInputValueChange,
|
||||
defaultInputValue,
|
||||
selectionMode = 'single',
|
||||
useOptionLabelAsInputValue,
|
||||
showEmptyMessage,
|
||||
floatingMaxHeight,
|
||||
hideEndAdornment = false,
|
||||
blurReferenceOnItemSelection,
|
||||
isOpen: propsIsOpen,
|
||||
onOpenChange: propsOnOpenChange,
|
||||
prependListbox,
|
||||
listboxClassName,
|
||||
onEndAdornmentClick,
|
||||
autoFocusFirstItem = true,
|
||||
...textFieldProps
|
||||
} = props;
|
||||
|
||||
const listbox = useListbox(
|
||||
{
|
||||
...props,
|
||||
floatingMaxHeight,
|
||||
blurReferenceOnItemSelection,
|
||||
selectionMode,
|
||||
role: 'listbox',
|
||||
virtualFocus: true,
|
||||
clearSelectionOnInputClear: true,
|
||||
},
|
||||
ref,
|
||||
);
|
||||
|
||||
const {
|
||||
reference,
|
||||
listboxId,
|
||||
onInputChange,
|
||||
state: {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
selectValues,
|
||||
selectedValues,
|
||||
setActiveCollection,
|
||||
},
|
||||
collection,
|
||||
} = listbox;
|
||||
|
||||
const textLabel = selectedValues[0]
|
||||
? collection.get(selectedValues[0])?.textLabel
|
||||
: undefined;
|
||||
|
||||
const {handleListboxSearchFieldKeydown} =
|
||||
useListboxKeyboardNavigation(listbox);
|
||||
|
||||
const handleFocusAndClick = createEventHandler(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (openMenuOnFocus && !isOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
e.target.select();
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
prepend={prependListbox}
|
||||
className={listboxClassName}
|
||||
listbox={listbox}
|
||||
mobileOverlay={Popover}
|
||||
isLoading={isLoading}
|
||||
onPointerDown={e => {
|
||||
// prevent focus from leaving input when scrolling listbox via mouse
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
inputRef={reference}
|
||||
{...textFieldProps}
|
||||
endAdornment={
|
||||
!hideEndAdornment ? (
|
||||
<IconButton
|
||||
size="md"
|
||||
tabIndex={-1}
|
||||
disabled={textFieldProps.disabled}
|
||||
className="pointer-events-auto"
|
||||
onPointerDown={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onEndAdornmentClick) {
|
||||
onEndAdornmentClick();
|
||||
} else {
|
||||
setActiveCollection('all');
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ComboboxEndAdornment
|
||||
isLoading={isLoading}
|
||||
icon={endAdornmentIcon}
|
||||
/>
|
||||
</IconButton>
|
||||
) : null
|
||||
}
|
||||
aria-expanded={isOpen ? 'true' : 'false'}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={isOpen ? listboxId : undefined}
|
||||
aria-autocomplete="list"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
onChange={onInputChange}
|
||||
value={useOptionLabelAsInputValue && textLabel ? textLabel : inputValue}
|
||||
onBlur={e => {
|
||||
if (allowCustomValue) {
|
||||
selectValues(e.target.value);
|
||||
} else if (!clearInputOnItemSelection) {
|
||||
const val = selectedValues[0];
|
||||
setInputValue(selectValues.length && val != null ? `${val}` : '');
|
||||
}
|
||||
}}
|
||||
onFocus={handleFocusAndClick}
|
||||
onClick={handleFocusAndClick}
|
||||
onKeyDown={e => handleListboxSearchFieldKeydown(e)}
|
||||
/>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
|
||||
const ComboBoxForwardRef = React.forwardRef(ComboBox) as <T extends object>(
|
||||
props: ComboboxProps<T> & {ref?: Ref<HTMLInputElement>},
|
||||
) => ReactElement;
|
||||
export {ComboBoxForwardRef as ComboBox};
|
||||
32
common/resources/client/ui/forms/combobox/form-combobox.tsx
Executable file
32
common/resources/client/ui/forms/combobox/form-combobox.tsx
Executable file
@@ -0,0 +1,32 @@
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import React from 'react';
|
||||
import {ComboBox, ComboboxProps} from './combobox';
|
||||
|
||||
type Props<T extends object> = ComboboxProps<T> & {
|
||||
name: string;
|
||||
selectionMode?: 'single';
|
||||
};
|
||||
export function FormComboBox<T extends object>({children, ...props}: Props<T>) {
|
||||
const {
|
||||
field: {onChange, onBlur, value = '', ref},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
});
|
||||
|
||||
const formProps: Partial<ComboboxProps<T>> = {
|
||||
onSelectionChange: onChange,
|
||||
onBlur,
|
||||
selectedValue: value,
|
||||
defaultInputValue: value,
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
};
|
||||
|
||||
return (
|
||||
<ComboBox ref={ref} {...mergeProps(formProps, props)}>
|
||||
{children}
|
||||
</ComboBox>
|
||||
);
|
||||
}
|
||||
44
common/resources/client/ui/forms/form.tsx
Executable file
44
common/resources/client/ui/forms/form.tsx
Executable file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
SubmitHandler,
|
||||
UseFormReturn,
|
||||
} from 'react-hook-form';
|
||||
import {FocusEventHandler, ReactNode} from 'react';
|
||||
|
||||
interface Props<T extends FieldValues> {
|
||||
children: ReactNode;
|
||||
form: UseFormReturn<T>;
|
||||
className?: string;
|
||||
onSubmit: SubmitHandler<T>;
|
||||
onBeforeSubmit?: () => void;
|
||||
onBlur?: FocusEventHandler<HTMLFormElement>;
|
||||
id?: string;
|
||||
}
|
||||
export function Form<T extends FieldValues>({
|
||||
children,
|
||||
onBeforeSubmit,
|
||||
onSubmit,
|
||||
form,
|
||||
className,
|
||||
id,
|
||||
onBlur,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
id={id}
|
||||
onBlur={onBlur}
|
||||
className={className}
|
||||
onSubmit={e => {
|
||||
// prevent parent forms from submitting, if nested
|
||||
e.stopPropagation();
|
||||
onBeforeSubmit?.();
|
||||
form.handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
28
common/resources/client/ui/forms/input-field/adornment.tsx
Executable file
28
common/resources/client/ui/forms/input-field/adornment.tsx
Executable file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type AdornmentProps = {
|
||||
children: React.ReactNode;
|
||||
direction: 'start' | 'end';
|
||||
position?: string;
|
||||
className?: string;
|
||||
};
|
||||
export function Adornment({
|
||||
children,
|
||||
direction,
|
||||
className,
|
||||
position = direction === 'start' ? 'left-0' : 'right-0',
|
||||
}: AdornmentProps) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute top-0 z-10 flex h-full min-w-42 items-center justify-center text-muted',
|
||||
position,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
common/resources/client/ui/forms/input-field/base-field-props.ts
Executable file
43
common/resources/client/ui/forms/input-field/base-field-props.ts
Executable file
@@ -0,0 +1,43 @@
|
||||
import React, {ElementType, HTMLProps, ReactElement, ReactNode} from 'react';
|
||||
import {InputSize} from './input-size';
|
||||
|
||||
export interface BaseFieldProps {
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
labelSuffix?: ReactNode;
|
||||
labelSuffixPosition?: 'spaced' | 'inline';
|
||||
autoFocus?: boolean;
|
||||
autoSelectText?: boolean;
|
||||
labelElementType?: ElementType;
|
||||
label?: ReactNode;
|
||||
labelPosition?: 'top' | 'side';
|
||||
labelDisplay?: string;
|
||||
size?: InputSize;
|
||||
inputRadius?: 'rounded-full' | 'rounded' | 'rounded-none' | string;
|
||||
inputRing?: string;
|
||||
inputFontSize?: string;
|
||||
inputBorder?: string;
|
||||
inputShadow?: string;
|
||||
invalid?: boolean;
|
||||
errorMessage?: ReactNode;
|
||||
description?: ReactNode;
|
||||
descriptionPosition?: 'top' | 'bottom';
|
||||
flexibleHeight?: boolean;
|
||||
// usually an icon or icon button, displayed inside the input
|
||||
startAdornment?: React.ReactNode;
|
||||
endAdornment?: React.ReactNode;
|
||||
adornmentPosition?: string;
|
||||
// usually a text button, displayed side by side with input
|
||||
startAppend?: ReactElement;
|
||||
endAppend?: ReactElement;
|
||||
className?: string;
|
||||
inputWrapperClassName?: string;
|
||||
inputClassName?: string;
|
||||
unstyled?: boolean;
|
||||
background?: 'bg-transparent' | 'bg-alt' | 'bg' | 'bg-white';
|
||||
inputTestId?: string;
|
||||
}
|
||||
|
||||
export interface BaseFieldPropsWithDom<T>
|
||||
extends BaseFieldProps,
|
||||
Omit<HTMLProps<T>, 'label' | 'size' | 'ref' | 'children'> {}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSvgIcon} from '../../../../icons/create-svg-icon';
|
||||
|
||||
export const CancelFilledIcon = createSvgIcon(
|
||||
<path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z" />
|
||||
);
|
||||
486
common/resources/client/ui/forms/input-field/chip-field/chip-field.tsx
Executable file
486
common/resources/client/ui/forms/input-field/chip-field/chip-field.tsx
Executable file
@@ -0,0 +1,486 @@
|
||||
import React, {
|
||||
HTMLAttributes,
|
||||
Key,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
Ref,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {useFocusManager} from '@react-aria/focus';
|
||||
import clsx from 'clsx';
|
||||
import {mergeProps, useLayoutEffect, useObjectRef} from '@react-aria/utils';
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import {ChipList} from './chip-list';
|
||||
import {Field, FieldProps} from '../field';
|
||||
import {Input} from '../input';
|
||||
import {Chip, ChipProps} from './chip';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {getInputFieldClassNames} from '../get-input-field-class-names';
|
||||
import {ProgressCircle} from '../../../progress/progress-circle';
|
||||
import {useField} from '../use-field';
|
||||
import {Avatar} from '../../../images/avatar';
|
||||
import {Listbox} from '../../listbox/listbox';
|
||||
import {useListbox} from '../../listbox/use-listbox';
|
||||
import {BaseFieldPropsWithDom} from '../base-field-props';
|
||||
import {useListboxKeyboardNavigation} from '../../listbox/use-listbox-keyboard-navigation';
|
||||
import {createEventHandler} from '@common/utils/dom/create-event-handler';
|
||||
import {ListBoxChildren, ListboxProps} from '../../listbox/types';
|
||||
import {stringToChipValue} from './string-to-chip-value';
|
||||
import {Popover} from '../../../overlays/popover';
|
||||
import {KeyboardArrowDownIcon} from '@common/icons/material/KeyboardArrowDown';
|
||||
|
||||
export interface ChipValue extends Omit<NormalizedModel, 'model_type'> {
|
||||
invalid?: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export type ChipFieldProps<T> = Omit<
|
||||
ListboxProps,
|
||||
'selectionMode' | 'displayWith'
|
||||
> &
|
||||
Omit<
|
||||
BaseFieldPropsWithDom<HTMLInputElement>,
|
||||
'value' | 'onChange' | 'defaultValue'
|
||||
> & {
|
||||
value?: (ChipValue | string)[];
|
||||
defaultValue?: (ChipValue | string)[];
|
||||
displayWith?: (value: ChipValue) => ReactNode;
|
||||
validateWith?: (value: ChipValue) => ChipValue;
|
||||
allowCustomValue?: boolean;
|
||||
showDropdownArrow?: boolean;
|
||||
onChange?: (value: ChipValue[]) => void;
|
||||
suggestions?: T[];
|
||||
children?: ListBoxChildren<T>['children'];
|
||||
placeholder?: string;
|
||||
chipSize?: ChipProps['size'];
|
||||
openMenuOnFocus?: boolean;
|
||||
valueKey?: 'id' | 'name';
|
||||
onChipClick?: (value: ChipValue) => void;
|
||||
};
|
||||
|
||||
function ChipFieldInner<T>(
|
||||
props: ChipFieldProps<T>,
|
||||
ref: Ref<HTMLInputElement>,
|
||||
) {
|
||||
const fieldRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useObjectRef(ref);
|
||||
const {
|
||||
displayWith = v => v.name,
|
||||
validateWith,
|
||||
children,
|
||||
suggestions,
|
||||
isLoading,
|
||||
inputValue,
|
||||
onInputValueChange,
|
||||
onItemSelected,
|
||||
placeholder,
|
||||
onOpenChange,
|
||||
chipSize = 'sm',
|
||||
openMenuOnFocus = true,
|
||||
showEmptyMessage,
|
||||
value: propsValue,
|
||||
defaultValue,
|
||||
onChange: propsOnChange,
|
||||
valueKey,
|
||||
isAsync,
|
||||
allowCustomValue = true,
|
||||
showDropdownArrow,
|
||||
onChipClick,
|
||||
...inputFieldProps
|
||||
} = props;
|
||||
const fieldClassNames = getInputFieldClassNames({
|
||||
...props,
|
||||
flexibleHeight: true,
|
||||
});
|
||||
|
||||
const [value, onChange] = useChipFieldValueState(props);
|
||||
|
||||
const [listboxIsOpen, setListboxIsOpen] = useState(false);
|
||||
|
||||
const loadingIndicator = (
|
||||
<ProgressCircle isIndeterminate size="sm" aria-label="loading..." />
|
||||
);
|
||||
|
||||
const dropdownArrow = showDropdownArrow ? <KeyboardArrowDownIcon /> : null;
|
||||
|
||||
const {fieldProps, inputProps} = useField({
|
||||
...inputFieldProps,
|
||||
focusRef: inputRef,
|
||||
endAdornment: isLoading && listboxIsOpen ? loadingIndicator : dropdownArrow,
|
||||
});
|
||||
|
||||
return (
|
||||
<Field fieldClassNames={fieldClassNames} {...fieldProps}>
|
||||
<Input
|
||||
ref={fieldRef}
|
||||
className={clsx('flex flex-wrap items-center', fieldClassNames.input)}
|
||||
onClick={() => {
|
||||
// refocus input when clicking outside it, but while still inside chip field
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<ListWrapper
|
||||
displayChipUsing={displayWith}
|
||||
onChipClick={onChipClick}
|
||||
items={value}
|
||||
setItems={onChange}
|
||||
chipSize={chipSize}
|
||||
/>
|
||||
<ChipInput
|
||||
size={props.size}
|
||||
showEmptyMessage={showEmptyMessage}
|
||||
inputProps={inputProps}
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={onInputValueChange}
|
||||
fieldRef={fieldRef}
|
||||
inputRef={inputRef}
|
||||
chips={value}
|
||||
setChips={onChange}
|
||||
validateWith={validateWith}
|
||||
isLoading={isLoading}
|
||||
suggestions={suggestions}
|
||||
placeholder={placeholder}
|
||||
openMenuOnFocus={openMenuOnFocus}
|
||||
listboxIsOpen={listboxIsOpen}
|
||||
setListboxIsOpen={setListboxIsOpen}
|
||||
allowCustomValue={allowCustomValue}
|
||||
>
|
||||
{children}
|
||||
</ChipInput>
|
||||
</Input>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListWrapperProps {
|
||||
items: ChipValue[];
|
||||
setItems: (items: ChipValue[]) => void;
|
||||
displayChipUsing: (value: ChipValue) => ReactNode;
|
||||
chipSize?: ChipProps['size'];
|
||||
onChipClick?: (value: ChipValue) => void;
|
||||
}
|
||||
function ListWrapper({
|
||||
items,
|
||||
setItems,
|
||||
displayChipUsing,
|
||||
chipSize,
|
||||
onChipClick,
|
||||
}: ListWrapperProps) {
|
||||
const manager = useFocusManager();
|
||||
const removeItem = useCallback(
|
||||
(key: Key) => {
|
||||
const i = items.findIndex(cr => cr.id === key);
|
||||
const newItems = [...items];
|
||||
if (i > -1) {
|
||||
newItems.splice(i, 1);
|
||||
setItems(newItems);
|
||||
}
|
||||
return newItems;
|
||||
},
|
||||
[items, setItems],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChipList
|
||||
className={clsx(
|
||||
'max-w-full flex-shrink-0 flex-wrap',
|
||||
chipSize === 'xs' ? 'my-6' : 'my-8',
|
||||
)}
|
||||
size={chipSize}
|
||||
selectable
|
||||
>
|
||||
{items.map(item => (
|
||||
<Chip
|
||||
key={item.id}
|
||||
errorMessage={item.errorMessage}
|
||||
adornment={item.image ? <Avatar circle src={item.image} /> : null}
|
||||
onClick={() => onChipClick?.(item)}
|
||||
onRemove={() => {
|
||||
const newItems = removeItem(item.id);
|
||||
if (newItems.length) {
|
||||
// focus previous chip
|
||||
manager?.focusPrevious({tabbable: true});
|
||||
} else {
|
||||
// focus input
|
||||
manager?.focusLast();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{displayChipUsing(item)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipList>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChipInputProps<T> {
|
||||
showEmptyMessage?: boolean;
|
||||
inputProps: ReturnType<typeof useField>['inputProps'];
|
||||
inputValue?: string;
|
||||
onInputValueChange?: (value: string) => void;
|
||||
fieldRef: RefObject<HTMLDivElement>;
|
||||
inputRef: RefObject<HTMLInputElement>;
|
||||
chips: ChipValue[];
|
||||
setChips: (items: ChipValue[]) => void;
|
||||
validateWith?: (value: ChipValue) => ChipValue;
|
||||
isLoading?: boolean;
|
||||
suggestions?: T[];
|
||||
placeholder?: string;
|
||||
openMenuOnFocus?: boolean;
|
||||
listboxIsOpen: boolean;
|
||||
setListboxIsOpen: (value: boolean) => void;
|
||||
allowCustomValue: boolean;
|
||||
children: ListBoxChildren<T>['children'];
|
||||
size: FieldProps['size'];
|
||||
}
|
||||
function ChipInput<T>(props: ChipInputProps<T>) {
|
||||
const {
|
||||
inputRef,
|
||||
fieldRef,
|
||||
validateWith,
|
||||
setChips,
|
||||
chips,
|
||||
suggestions,
|
||||
inputProps,
|
||||
placeholder,
|
||||
openMenuOnFocus,
|
||||
listboxIsOpen,
|
||||
setListboxIsOpen,
|
||||
allowCustomValue,
|
||||
isLoading,
|
||||
size,
|
||||
} = props;
|
||||
const manager = useFocusManager();
|
||||
|
||||
const addItems = useCallback(
|
||||
(items?: ChipValue[]) => {
|
||||
items = (items || []).filter(item => {
|
||||
const invalid = !item || !item.id || !item.name;
|
||||
const alreadyExists = chips.findIndex(cr => cr.id === item?.id) > -1;
|
||||
return !alreadyExists && !invalid;
|
||||
});
|
||||
if (!items.length) return;
|
||||
|
||||
if (validateWith) {
|
||||
items = items.map(item => validateWith(item));
|
||||
}
|
||||
setChips([...chips, ...items]);
|
||||
},
|
||||
[chips, setChips, validateWith],
|
||||
);
|
||||
|
||||
const listbox = useListbox<T>({
|
||||
...props,
|
||||
clearInputOnItemSelection: true,
|
||||
isOpen: listboxIsOpen,
|
||||
onOpenChange: setListboxIsOpen,
|
||||
items: suggestions,
|
||||
selectionMode: 'none',
|
||||
role: 'listbox',
|
||||
virtualFocus: true,
|
||||
onItemSelected: value => {
|
||||
handleItemSelection(value as string);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
state: {
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
},
|
||||
refs,
|
||||
listboxId,
|
||||
collection,
|
||||
onInputChange,
|
||||
} = listbox;
|
||||
|
||||
const handleItemSelection = (textValue: string) => {
|
||||
const option =
|
||||
collection.size && activeIndex != null
|
||||
? [...collection.values()][activeIndex]
|
||||
: null;
|
||||
if (option?.item) {
|
||||
addItems([option.item]);
|
||||
} else if (allowCustomValue) {
|
||||
addItems([stringToChipValue(option ? option.value : textValue)]);
|
||||
}
|
||||
|
||||
setInputValue('');
|
||||
setActiveIndex(null);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// position dropdown relative to whole chip field, not the input
|
||||
useLayoutEffect(() => {
|
||||
if (fieldRef.current && refs.reference.current !== fieldRef.current) {
|
||||
listbox.reference(fieldRef.current);
|
||||
}
|
||||
}, [fieldRef, listbox, refs]);
|
||||
|
||||
const {handleTriggerKeyDown, handleListboxKeyboardNavigation} =
|
||||
useListboxKeyboardNavigation(listbox);
|
||||
|
||||
const handleFocusAndClick = createEventHandler(() => {
|
||||
if (openMenuOnFocus && !isOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
listbox={listbox}
|
||||
mobileOverlay={Popover}
|
||||
isLoading={isLoading}
|
||||
onPointerDown={e => {
|
||||
// prevent focus from leaving input when scrolling listbox via mouse
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className={clsx(
|
||||
'mx-8 my-4 min-w-30 flex-[1_1_60px] bg-transparent text-sm outline-none',
|
||||
size === 'xs' ? 'h-20' : 'h-30',
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
{...mergeProps(inputProps, {
|
||||
ref: inputRef,
|
||||
value: inputValue,
|
||||
onChange: onInputChange,
|
||||
onPaste: e => {
|
||||
const paste = e.clipboardData.getData('text');
|
||||
const emails = paste.match(
|
||||
/([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)/gi,
|
||||
);
|
||||
if (emails) {
|
||||
e.preventDefault();
|
||||
const selection = window.getSelection();
|
||||
if (selection?.rangeCount) {
|
||||
selection.deleteFromDocument();
|
||||
addItems(emails.map(email => stringToChipValue(email)));
|
||||
}
|
||||
}
|
||||
},
|
||||
'aria-autocomplete': 'list',
|
||||
'aria-controls': isOpen ? listboxId : undefined,
|
||||
autoComplete: 'off',
|
||||
autoCorrect: 'off',
|
||||
spellCheck: 'false',
|
||||
onKeyDown: e => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
// prevent form submitting
|
||||
e.preventDefault();
|
||||
// add chip from selected listbox option or current input text value
|
||||
handleItemSelection(input.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// on escape, clear input and close dropdown
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
setIsOpen(false);
|
||||
setInputValue('');
|
||||
}
|
||||
|
||||
// move focus to input when focus is on first item and prevent arrow up from cycling listbox
|
||||
if (
|
||||
e.key === 'ArrowUp' &&
|
||||
isOpen &&
|
||||
(activeIndex === 0 || activeIndex == null)
|
||||
) {
|
||||
setActiveIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// block left and right arrows from navigating in input, if focus is on listbox
|
||||
if (
|
||||
activeIndex != null &&
|
||||
(e.key === 'ArrowLeft' || e.key === 'ArrowRight')
|
||||
) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// move focus on the last chip, if focus is at the start of input
|
||||
if (
|
||||
(e.key === 'ArrowLeft' ||
|
||||
e.key === 'Backspace' ||
|
||||
e.key === 'Delete') &&
|
||||
input.selectionStart === 0 &&
|
||||
activeIndex == null &&
|
||||
chips.length
|
||||
) {
|
||||
manager?.focusPrevious({tabbable: true});
|
||||
return;
|
||||
}
|
||||
|
||||
// fallthrough to listbox navigation handlers for arrow keys
|
||||
const handled = handleTriggerKeyDown(e);
|
||||
if (!handled) {
|
||||
handleListboxKeyboardNavigation(e);
|
||||
}
|
||||
},
|
||||
onFocus: handleFocusAndClick,
|
||||
onClick: handleFocusAndClick,
|
||||
} as HTMLAttributes<HTMLInputElement>)}
|
||||
/>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
|
||||
function useChipFieldValueState({
|
||||
onChange,
|
||||
value,
|
||||
defaultValue,
|
||||
valueKey,
|
||||
}: ChipFieldProps<any>) {
|
||||
// convert value from string[] to ChipValue[], if needed
|
||||
const propsValue = useMemo(() => {
|
||||
return mixedValueToChipValue(value);
|
||||
}, [value]);
|
||||
|
||||
// convert defaultValue from string[] to ChipValue[], if needed
|
||||
const propsDefaultValue = useMemo(() => {
|
||||
return mixedValueToChipValue(defaultValue);
|
||||
}, [defaultValue]);
|
||||
|
||||
// emit string[] or ChipValue[] on change, based on "valueKey" prop
|
||||
const handleChange = useCallback(
|
||||
(value: ChipValue[]) => {
|
||||
const newValue = valueKey ? value.map(v => v[valueKey]) : value;
|
||||
onChange?.(newValue as any);
|
||||
},
|
||||
[onChange, valueKey],
|
||||
);
|
||||
|
||||
return useControlledState<ChipValue[]>(
|
||||
!propsValue ? undefined : propsValue,
|
||||
propsDefaultValue || [],
|
||||
handleChange,
|
||||
);
|
||||
}
|
||||
|
||||
function mixedValueToChipValue(
|
||||
value?: (string | number | ChipValue)[] | null,
|
||||
): ChipValue[] | undefined {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.map(v => {
|
||||
return typeof v !== 'object' ? stringToChipValue(v as string) : v;
|
||||
});
|
||||
}
|
||||
|
||||
export const ChipField = React.forwardRef(ChipFieldInner) as <T>(
|
||||
props: ChipFieldProps<T> & {ref?: Ref<HTMLInputElement>},
|
||||
) => ReactElement;
|
||||
48
common/resources/client/ui/forms/input-field/chip-field/chip-list.tsx
Executable file
48
common/resources/client/ui/forms/input-field/chip-field/chip-list.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
import React, {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import type {ChipProps} from './chip';
|
||||
|
||||
export interface ChipListProps {
|
||||
className?: string;
|
||||
children?: ReactElement | ReactElement[];
|
||||
size?: ChipProps['size'];
|
||||
radius?: ChipProps['radius'];
|
||||
color?: ChipProps['color'];
|
||||
selectable?: ChipProps['selectable'];
|
||||
wrap?: boolean;
|
||||
}
|
||||
export function ChipList({
|
||||
className,
|
||||
children,
|
||||
size,
|
||||
color,
|
||||
radius,
|
||||
selectable,
|
||||
wrap = true,
|
||||
}: ChipListProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-8',
|
||||
wrap && 'flex-wrap',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{Children.map(children, chip => {
|
||||
if (isValidElement<ChipProps>(chip)) {
|
||||
return cloneElement<ChipProps>(chip, {
|
||||
size,
|
||||
color,
|
||||
selectable,
|
||||
radius,
|
||||
});
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
common/resources/client/ui/forms/input-field/chip-field/chip.tsx
Executable file
191
common/resources/client/ui/forms/input-field/chip-field/chip.tsx
Executable file
@@ -0,0 +1,191 @@
|
||||
import React, {
|
||||
cloneElement,
|
||||
JSXElementConstructor,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useFocusManager} from '@react-aria/focus';
|
||||
import {ButtonBase} from '../../../buttons/button-base';
|
||||
import {CancelFilledIcon} from './cancel-filled-icon';
|
||||
import {WarningIcon} from '@common/icons/material/Warning';
|
||||
import {Tooltip} from '../../../tooltip/tooltip';
|
||||
import {To} from 'react-router-dom';
|
||||
|
||||
export interface ChipProps {
|
||||
onRemove?: () => void;
|
||||
disabled?: boolean;
|
||||
selectable?: boolean;
|
||||
invalid?: boolean;
|
||||
errorMessage?: ReactElement | string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
adornment?: null | ReactElement<{
|
||||
size: string;
|
||||
className?: string;
|
||||
circle?: boolean;
|
||||
}>;
|
||||
radius?: string;
|
||||
color?: 'chip' | 'primary' | 'danger' | 'positive';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
elementType?: 'div' | 'a' | JSXElementConstructor<any>;
|
||||
to?: To;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
export function Chip(props: ChipProps) {
|
||||
const {
|
||||
onRemove,
|
||||
disabled,
|
||||
invalid,
|
||||
errorMessage,
|
||||
children,
|
||||
className,
|
||||
selectable = false,
|
||||
radius = 'rounded-full',
|
||||
elementType = 'div',
|
||||
to,
|
||||
onClick,
|
||||
} = props;
|
||||
const chipRef = useRef<HTMLDivElement>(null);
|
||||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const focusManager = useFocusManager();
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
focusManager?.focusNext({tabbable: true});
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
focusManager?.focusPrevious({tabbable: true});
|
||||
break;
|
||||
case 'Backspace':
|
||||
case 'Delete':
|
||||
if (chipRef.current === document.activeElement) {
|
||||
onRemove?.();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick: React.MouseEventHandler = e => {
|
||||
e.stopPropagation();
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
} else {
|
||||
chipRef.current!.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const sizeStyle = sizeClassNames(props);
|
||||
|
||||
let adornment =
|
||||
invalid || errorMessage != null ? (
|
||||
<WarningIcon className="text-danger" size="sm" />
|
||||
) : (
|
||||
props.adornment &&
|
||||
cloneElement(props.adornment, {
|
||||
size: sizeStyle.adornment.size,
|
||||
circle: true,
|
||||
className: clsx(props.adornment.props, sizeStyle.adornment.margin),
|
||||
})
|
||||
);
|
||||
|
||||
if (errorMessage && adornment) {
|
||||
adornment = (
|
||||
<Tooltip label={errorMessage} variant="danger">
|
||||
{adornment}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const Element = elementType;
|
||||
|
||||
return (
|
||||
<Element
|
||||
tabIndex={selectable ? 0 : undefined}
|
||||
ref={chipRef}
|
||||
to={to}
|
||||
onKeyDown={selectable ? handleKeyDown : undefined}
|
||||
onClick={selectable ? handleClick : undefined}
|
||||
className={clsx(
|
||||
'relative flex flex-shrink-0 items-center justify-center gap-10 overflow-hidden whitespace-nowrap outline-none',
|
||||
'min-w-0 max-w-full after:pointer-events-none after:absolute after:inset-0',
|
||||
onClick && 'cursor-pointer',
|
||||
radius,
|
||||
colorClassName(props),
|
||||
sizeStyle.chip,
|
||||
!disabled &&
|
||||
selectable &&
|
||||
'hover:after:bg-black/5 focus:after:bg-black/10',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{adornment}
|
||||
<div className="flex-auto overflow-hidden overflow-ellipsis">
|
||||
{children}
|
||||
</div>
|
||||
{onRemove && (
|
||||
<ButtonBase
|
||||
ref={deleteButtonRef}
|
||||
className={clsx(
|
||||
'text-black/30 dark:text-white/50',
|
||||
sizeStyle.closeButton,
|
||||
)}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<CancelFilledIcon className="block" width="100%" height="100%" />
|
||||
</ButtonBase>
|
||||
)}
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
|
||||
function sizeClassNames({size, onRemove}: ChipProps) {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return {
|
||||
adornment: {size: 'xs', margin: '-ml-3'},
|
||||
chip: clsx('pl-8 h-20 text-xs font-medium w-max', !onRemove && 'pr-8'),
|
||||
closeButton: 'mr-4 w-14 h-14',
|
||||
};
|
||||
case 'sm':
|
||||
return {
|
||||
adornment: {size: 'xs', margin: '-ml-3'},
|
||||
chip: clsx('pl-8 h-26 text-xs', !onRemove && 'pr-8'),
|
||||
closeButton: 'mr-4 w-18 h-18',
|
||||
};
|
||||
case 'lg':
|
||||
return {
|
||||
adornment: {size: 'md', margin: '-ml-12'},
|
||||
chip: clsx('pl-18 h-38 text-base', !onRemove && 'pr-18'),
|
||||
closeButton: 'mr-6 w-24 h-24',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
adornment: {size: 'sm', margin: '-ml-6'},
|
||||
chip: clsx('pl-12 h-32 text-sm', !onRemove && 'pr-12'),
|
||||
closeButton: 'mr-6 w-22 h-22',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function colorClassName({color}: ChipProps): string {
|
||||
switch (color) {
|
||||
case 'primary':
|
||||
return `bg-primary text-on-primary`;
|
||||
case 'positive':
|
||||
return `bg-positive-lighter text-positive-darker`;
|
||||
case 'danger':
|
||||
return `bg-danger-lighter text-danger-darker`;
|
||||
default:
|
||||
return `bg-chip text-main`;
|
||||
}
|
||||
}
|
||||
31
common/resources/client/ui/forms/input-field/chip-field/form-chip-field.tsx
Executable file
31
common/resources/client/ui/forms/input-field/chip-field/form-chip-field.tsx
Executable file
@@ -0,0 +1,31 @@
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import React from 'react';
|
||||
import {ChipField, ChipFieldProps} from './chip-field';
|
||||
|
||||
export type FormChipFieldProps<T> = ChipFieldProps<T> & {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function FormChipField<T>({children, ...props}: FormChipFieldProps<T>) {
|
||||
const {
|
||||
field: {onChange, onBlur, value = [], ref},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
});
|
||||
|
||||
const formProps: Partial<ChipFieldProps<T>> = {
|
||||
onChange,
|
||||
onBlur,
|
||||
value,
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
};
|
||||
|
||||
return (
|
||||
<ChipField ref={ref} {...mergeProps(formProps, props)}>
|
||||
{children}
|
||||
</ChipField>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {ChipValue} from './chip-field';
|
||||
|
||||
export function stringToChipValue(value: string | number): ChipValue {
|
||||
// add both name and description so "validateWith" works properly in chip field, if it depends on description
|
||||
return {id: value, name: `${value}`, description: `${value}`};
|
||||
}
|
||||
81
common/resources/client/ui/forms/input-field/date/calendar/calendar-cell.tsx
Executable file
81
common/resources/client/ui/forms/input-field/date/calendar/calendar-cell.tsx
Executable file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
CalendarDate,
|
||||
DateValue,
|
||||
getDayOfWeek,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
} from '@internationalized/date';
|
||||
import {useSelectedLocale} from '../../../../../i18n/selected-locale';
|
||||
import {DatePickerState} from '../date-picker/use-date-picker-state';
|
||||
import {dateIsInvalid} from '../utils';
|
||||
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
|
||||
|
||||
interface CalendarCellProps {
|
||||
date: CalendarDate;
|
||||
currentMonth: DateValue;
|
||||
state: DatePickerState | DateRangePickerState;
|
||||
}
|
||||
export function CalendarCell({
|
||||
date,
|
||||
currentMonth,
|
||||
state: {
|
||||
dayIsActive,
|
||||
dayIsHighlighted,
|
||||
dayIsRangeStart,
|
||||
dayIsRangeEnd,
|
||||
getCellProps,
|
||||
timezone,
|
||||
min,
|
||||
max,
|
||||
},
|
||||
}: CalendarCellProps) {
|
||||
const {localeCode} = useSelectedLocale();
|
||||
const dayOfWeek = getDayOfWeek(date, localeCode);
|
||||
const isActive = dayIsActive(date);
|
||||
const isHighlighted = dayIsHighlighted(date);
|
||||
const isRangeStart = dayIsRangeStart(date);
|
||||
const isRangeEnd = dayIsRangeEnd(date);
|
||||
const dayIsToday = isToday(date, timezone);
|
||||
const sameMonth = isSameMonth(date, currentMonth);
|
||||
const isDisabled = dateIsInvalid(date, min, max);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
aria-disabled={isDisabled}
|
||||
className={clsx(
|
||||
'w-40 h-40 text-sm relative isolate flex-shrink-0',
|
||||
isDisabled && 'text-disabled pointer-events-none',
|
||||
!sameMonth && 'invisible pointer-events-none'
|
||||
)}
|
||||
{...getCellProps(date, sameMonth)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute inset-0 flex items-center justify-center rounded-full w-full h-full select-none z-10 cursor-pointer',
|
||||
!isActive && !dayIsToday && 'hover:bg-hover',
|
||||
isActive && 'bg-primary text-on-primary font-semibold',
|
||||
dayIsToday && !isActive && 'bg-chip'
|
||||
)}
|
||||
>
|
||||
{date.day}
|
||||
</span>
|
||||
{isHighlighted && sameMonth && (
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute w-full h-full inset-0 bg-primary/focus',
|
||||
(isRangeStart || dayOfWeek === 0 || date.day === 1) &&
|
||||
'rounded-l-full',
|
||||
(isRangeEnd ||
|
||||
dayOfWeek === 6 ||
|
||||
date.day ===
|
||||
currentMonth.calendar.getDaysInMonth(currentMonth)) &&
|
||||
'rounded-r-full'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
common/resources/client/ui/forms/input-field/date/calendar/calendar-month.tsx
Executable file
169
common/resources/client/ui/forms/input-field/date/calendar/calendar-month.tsx
Executable file
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {m} from 'framer-motion';
|
||||
import {
|
||||
CalendarDate,
|
||||
endOfMonth,
|
||||
getWeeksInMonth,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
} from '@internationalized/date';
|
||||
import {KeyboardArrowLeftIcon} from '../../../../../icons/material/KeyboardArrowLeft';
|
||||
import {IconButton} from '../../../../buttons/icon-button';
|
||||
import {KeyboardArrowRightIcon} from '../../../../../icons/material/KeyboardArrowRight';
|
||||
import {CalendarCell} from './calendar-cell';
|
||||
import {DatePickerState} from '../date-picker/use-date-picker-state';
|
||||
import {useDateFormatter} from '../../../../../i18n/use-date-formatter';
|
||||
import {useSelectedLocale} from '../../../../../i18n/selected-locale';
|
||||
import {dateIsInvalid} from '../utils';
|
||||
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
|
||||
|
||||
export interface CalendarMonthProps {
|
||||
state: DatePickerState | DateRangePickerState;
|
||||
startDate: CalendarDate;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
}
|
||||
export function CalendarMonth({
|
||||
startDate,
|
||||
state,
|
||||
isFirst,
|
||||
isLast,
|
||||
}: CalendarMonthProps) {
|
||||
const {localeCode} = useSelectedLocale();
|
||||
const weeksInMonth = getWeeksInMonth(startDate, localeCode);
|
||||
const monthStart = startOfWeek(startDate, localeCode);
|
||||
|
||||
return (
|
||||
<div className="w-280 flex-shrink-0">
|
||||
<CalendarMonthHeader
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
state={state}
|
||||
currentMonth={startDate}
|
||||
/>
|
||||
<div className="block" role="grid">
|
||||
<WeekdayHeader state={state} startDate={startDate} />
|
||||
{[...new Array(weeksInMonth).keys()].map(weekIndex => (
|
||||
<m.div className="flex mb-6" key={weekIndex}>
|
||||
{[...new Array(7).keys()].map(dayIndex => (
|
||||
<CalendarCell
|
||||
key={dayIndex}
|
||||
date={monthStart.add({weeks: weekIndex, days: dayIndex})}
|
||||
currentMonth={startDate}
|
||||
state={state}
|
||||
/>
|
||||
))}
|
||||
</m.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CalendarMonthHeaderProps {
|
||||
state: DatePickerState | DateRangePickerState;
|
||||
currentMonth: CalendarDate;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
}
|
||||
function CalendarMonthHeader({
|
||||
currentMonth,
|
||||
isFirst,
|
||||
isLast,
|
||||
state: {calendarDates, setCalendarDates, timezone, min, max},
|
||||
}: CalendarMonthHeaderProps) {
|
||||
const shiftCalendars = (direction: 'forward' | 'backward') => {
|
||||
const count = calendarDates.length;
|
||||
let newDates: CalendarDate[];
|
||||
if (direction === 'forward') {
|
||||
newDates = calendarDates.map(date =>
|
||||
endOfMonth(date.add({months: count}))
|
||||
);
|
||||
} else {
|
||||
newDates = calendarDates.map(date =>
|
||||
endOfMonth(date.subtract({months: count}))
|
||||
);
|
||||
}
|
||||
setCalendarDates(newDates);
|
||||
};
|
||||
|
||||
const monthFormatter = useDateFormatter({
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
era: currentMonth.calendar.identifier !== 'gregory' ? 'long' : undefined,
|
||||
calendar: currentMonth.calendar.identifier,
|
||||
});
|
||||
|
||||
const isBackwardDisabled = dateIsInvalid(
|
||||
currentMonth.subtract({days: 1}),
|
||||
min,
|
||||
max
|
||||
);
|
||||
const isForwardDisabled = dateIsInvalid(
|
||||
startOfMonth(currentMonth.add({months: 1})),
|
||||
min,
|
||||
max
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-10">
|
||||
<IconButton
|
||||
size="md"
|
||||
className={clsx('text-muted', !isFirst && 'invisible')}
|
||||
disabled={!isFirst || isBackwardDisabled}
|
||||
aria-hidden={!isFirst}
|
||||
onClick={() => {
|
||||
shiftCalendars('backward');
|
||||
}}
|
||||
>
|
||||
<KeyboardArrowLeftIcon />
|
||||
</IconButton>
|
||||
<div className="text-sm font-semibold select-none">
|
||||
{monthFormatter.format(currentMonth.toDate(timezone))}
|
||||
</div>
|
||||
<IconButton
|
||||
size="md"
|
||||
className={clsx('text-muted', !isLast && 'invisible')}
|
||||
disabled={!isLast || isForwardDisabled}
|
||||
aria-hidden={!isLast}
|
||||
onClick={() => {
|
||||
shiftCalendars('forward');
|
||||
}}
|
||||
>
|
||||
<KeyboardArrowRightIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WeekdayHeaderProps {
|
||||
state: DatePickerState | DateRangePickerState;
|
||||
startDate: CalendarDate;
|
||||
}
|
||||
function WeekdayHeader({state: {timezone}, startDate}: WeekdayHeaderProps) {
|
||||
const {localeCode} = useSelectedLocale();
|
||||
const dayFormatter = useDateFormatter({weekday: 'short'});
|
||||
|
||||
const monthStart = startOfWeek(startDate, localeCode);
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{[...new Array(7).keys()].map(index => {
|
||||
const date = monthStart.add({days: index});
|
||||
const dateDay = date.toDate(timezone);
|
||||
const weekday = dayFormatter.format(dateDay);
|
||||
return (
|
||||
<div
|
||||
className="w-40 h-40 text-sm font-semibold relative flex-shrink-0"
|
||||
key={index}
|
||||
>
|
||||
<div className="absolute flex items-center justify-center w-full h-full select-none">
|
||||
{weekday}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
common/resources/client/ui/forms/input-field/date/calendar/calendar.tsx
Executable file
39
common/resources/client/ui/forms/input-field/date/calendar/calendar.tsx
Executable file
@@ -0,0 +1,39 @@
|
||||
import React, {Fragment} from 'react';
|
||||
import {startOfMonth, toCalendarDate} from '@internationalized/date';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
import {CalendarMonth} from './calendar-month';
|
||||
import {DatePickerState} from '../date-picker/use-date-picker-state';
|
||||
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
|
||||
|
||||
interface CalendarProps {
|
||||
state: DatePickerState | DateRangePickerState;
|
||||
visibleMonths?: 1 | 2;
|
||||
}
|
||||
export function Calendar({state, visibleMonths = 1}: CalendarProps) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
|
||||
if (isMobile) {
|
||||
visibleMonths = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{[...new Array(visibleMonths).keys()].map(index => {
|
||||
const startDate = toCalendarDate(
|
||||
startOfMonth(state.calendarDates[index])
|
||||
);
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === visibleMonths - 1;
|
||||
return (
|
||||
<CalendarMonth
|
||||
key={index}
|
||||
state={state}
|
||||
startDate={startDate}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
181
common/resources/client/ui/forms/input-field/date/date-picker/date-picker.tsx
Executable file
181
common/resources/client/ui/forms/input-field/date/date-picker/date-picker.tsx
Executable file
@@ -0,0 +1,181 @@
|
||||
import React, {
|
||||
ComponentPropsWithoutRef,
|
||||
Fragment,
|
||||
MouseEvent,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {parseAbsoluteToLocal, ZonedDateTime} from '@internationalized/date';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import {
|
||||
DatePickerValueProps,
|
||||
useDatePickerState,
|
||||
} from './use-date-picker-state';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {DateRangeIcon} from '@common/icons/material/DateRange';
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {Calendar} from '../calendar/calendar';
|
||||
import {
|
||||
DatePickerField,
|
||||
DatePickerFieldProps,
|
||||
} from '../date-range-picker/date-picker-field';
|
||||
import {DateSegmentList} from '../segments/date-segment-list';
|
||||
import {useDateFormatter} from '@common/i18n/use-date-formatter';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import clsx from 'clsx';
|
||||
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {useCurrentDateTime} from '@common/i18n/use-current-date-time';
|
||||
|
||||
export interface DatePickerProps
|
||||
extends Omit<DatePickerFieldProps, 'children'>,
|
||||
DatePickerValueProps<ZonedDateTime> {}
|
||||
export function DatePicker({showCalendarFooter, ...props}: DatePickerProps) {
|
||||
const state = useDatePickerState(props);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
const now = useCurrentDateTime();
|
||||
|
||||
const footer = showCalendarFooter && (
|
||||
<DialogFooter
|
||||
padding="px-14 pb-14"
|
||||
startAction={
|
||||
<Button
|
||||
disabled={state.isPlaceholder}
|
||||
variant="text"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
state.clear();
|
||||
}}
|
||||
>
|
||||
<Trans message="Clear" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
state.setSelectedValue(now);
|
||||
state.setCalendarIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trans message="Today" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
|
||||
const dialog = (
|
||||
<DialogTrigger
|
||||
offset={8}
|
||||
placement="bottom-start"
|
||||
isOpen={state.calendarIsOpen}
|
||||
onOpenChange={state.setCalendarIsOpen}
|
||||
type="popover"
|
||||
triggerRef={inputRef}
|
||||
returnFocusToTrigger={false}
|
||||
moveFocusToDialog={false}
|
||||
>
|
||||
<Dialog size="auto">
|
||||
<DialogBody
|
||||
className="flex items-start gap-40"
|
||||
padding={showCalendarFooter ? 'px-24 pt-20 pb-10' : null}
|
||||
>
|
||||
<Calendar state={state} visibleMonths={1} />
|
||||
</DialogBody>
|
||||
{footer}
|
||||
</Dialog>
|
||||
</DialogTrigger>
|
||||
);
|
||||
|
||||
const openOnClick: ComponentPropsWithoutRef<'div'> = {
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isHourSegment(e)) {
|
||||
state.setCalendarIsOpen(true);
|
||||
} else {
|
||||
state.setCalendarIsOpen(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<DatePickerField
|
||||
ref={inputRef}
|
||||
wrapperProps={openOnClick}
|
||||
endAdornment={
|
||||
<DateRangeIcon className={clsx(props.disabled && 'text-disabled')} />
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<DateSegmentList
|
||||
segmentProps={openOnClick}
|
||||
state={state}
|
||||
value={state.selectedValue}
|
||||
onChange={state.setSelectedValue}
|
||||
isPlaceholder={state.isPlaceholder}
|
||||
/>
|
||||
</DatePickerField>
|
||||
{dialog}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormDatePickerProps extends DatePickerProps {
|
||||
name: string;
|
||||
}
|
||||
export function FormDatePicker(props: FormDatePickerProps) {
|
||||
const {min, max} = props;
|
||||
const {trans} = useTrans();
|
||||
const {format} = useDateFormatter();
|
||||
const {
|
||||
field: {onChange, onBlur, value = null, ref},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
rules: {
|
||||
validate: v => {
|
||||
if (!v) return;
|
||||
const date = parseAbsoluteToLocal(v);
|
||||
if (min && date.compare(min) < 0) {
|
||||
return trans({
|
||||
message: 'Enter a date after :date',
|
||||
values: {date: format(v)},
|
||||
});
|
||||
}
|
||||
if (max && date.compare(max) > 0) {
|
||||
return trans({
|
||||
message: 'Enter a date before :date',
|
||||
values: {date: format(v)},
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const parsedValue: null | ZonedDateTime = value
|
||||
? parseAbsoluteToLocal(value)
|
||||
: null;
|
||||
|
||||
const formProps: Partial<DatePickerProps> = {
|
||||
onChange: e => {
|
||||
onChange(e ? e.toAbsoluteString() : e);
|
||||
},
|
||||
onBlur,
|
||||
value: parsedValue,
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
inputRef: ref,
|
||||
};
|
||||
|
||||
return <DatePicker {...mergeProps(formProps, props)} />;
|
||||
}
|
||||
|
||||
function isHourSegment(e: MouseEvent<HTMLDivElement>): boolean {
|
||||
return ['hour', 'minute', 'dayPeriod'].includes(
|
||||
(e.currentTarget as HTMLElement).ariaLabel || ''
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import {HTMLAttributes, useCallback, useState} from 'react';
|
||||
import {
|
||||
CalendarDate,
|
||||
DateValue,
|
||||
isSameDay,
|
||||
toCalendarDate,
|
||||
toZoned,
|
||||
ZonedDateTime,
|
||||
} from '@internationalized/date';
|
||||
import {useBaseDatePickerState} from '../use-base-date-picker-state';
|
||||
import {useCurrentDateTime} from '@common/i18n/use-current-date-time';
|
||||
|
||||
export type Granularity = 'day' | 'minute';
|
||||
|
||||
export type DatePickerState = BaseDatePickerState;
|
||||
|
||||
export interface BaseDatePickerState<T = ZonedDateTime, P = boolean> {
|
||||
timezone: string;
|
||||
granularity: Granularity;
|
||||
selectedValue: T;
|
||||
setSelectedValue: (value: T) => void;
|
||||
calendarIsOpen: boolean;
|
||||
setCalendarIsOpen: (isOpen: boolean) => void;
|
||||
calendarDates: CalendarDate[];
|
||||
setCalendarDates: (dates: CalendarDate[]) => void;
|
||||
dayIsActive: (day: CalendarDate) => boolean;
|
||||
dayIsHighlighted: (day: CalendarDate) => boolean;
|
||||
dayIsRangeStart: (day: CalendarDate) => boolean;
|
||||
dayIsRangeEnd: (day: CalendarDate) => boolean;
|
||||
isPlaceholder: P;
|
||||
setIsPlaceholder: (value: P) => void;
|
||||
clear: () => void;
|
||||
min?: ZonedDateTime;
|
||||
max?: ZonedDateTime;
|
||||
closeDialogOnSelection: boolean;
|
||||
getCellProps: (
|
||||
date: CalendarDate,
|
||||
isSameMonth: boolean,
|
||||
) => HTMLAttributes<HTMLElement>;
|
||||
}
|
||||
|
||||
export interface DatePickerValueProps<V, CV = V> {
|
||||
value?: V | null | '';
|
||||
defaultValue?: V | null;
|
||||
onChange?: (value: CV | null) => void;
|
||||
min?: DateValue;
|
||||
max?: DateValue;
|
||||
granularity?: Granularity;
|
||||
closeDialogOnSelection?: boolean;
|
||||
}
|
||||
export function useDatePickerState(
|
||||
props: DatePickerValueProps<ZonedDateTime>,
|
||||
): BaseDatePickerState {
|
||||
const now = useCurrentDateTime();
|
||||
const [isPlaceholder, setIsPlaceholder] = useState(
|
||||
!props.value && !props.defaultValue,
|
||||
);
|
||||
|
||||
// if user clears the date, we will want to still keep an
|
||||
// instance internally, but return null via "onChange" callback
|
||||
const setStateValue = props.onChange;
|
||||
const [internalValue, setInternalValue] = useControlledState(
|
||||
props.value || now,
|
||||
props.defaultValue || now,
|
||||
value => {
|
||||
setIsPlaceholder(false);
|
||||
setStateValue?.(value);
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
granularity,
|
||||
timezone,
|
||||
calendarIsOpen,
|
||||
setCalendarIsOpen,
|
||||
closeDialogOnSelection,
|
||||
} = useBaseDatePickerState(internalValue, props);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setIsPlaceholder(true);
|
||||
setInternalValue(now);
|
||||
setStateValue?.(null);
|
||||
setCalendarIsOpen(false);
|
||||
}, [now, setInternalValue, setStateValue, setCalendarIsOpen]);
|
||||
|
||||
const [calendarDates, setCalendarDates] = useState<CalendarDate[]>(() => {
|
||||
return [toCalendarDate(internalValue)];
|
||||
});
|
||||
|
||||
const setSelectedValue = useCallback(
|
||||
(newValue: DateValue) => {
|
||||
if (min && newValue.compare(min) < 0) {
|
||||
newValue = min;
|
||||
} else if (max && newValue.compare(max) > 0) {
|
||||
newValue = max;
|
||||
}
|
||||
|
||||
// preserve time
|
||||
const value = internalValue
|
||||
? internalValue.set(newValue)
|
||||
: toZoned(newValue, timezone);
|
||||
setInternalValue(value);
|
||||
setCalendarDates([toCalendarDate(value)]);
|
||||
setIsPlaceholder(false);
|
||||
},
|
||||
[setInternalValue, min, max, internalValue, timezone],
|
||||
);
|
||||
|
||||
const dayIsActive = useCallback(
|
||||
(day: DateValue) => !isPlaceholder && isSameDay(internalValue, day),
|
||||
[internalValue, isPlaceholder],
|
||||
);
|
||||
|
||||
const getCellProps = useCallback(
|
||||
(date: DateValue): HTMLAttributes<HTMLElement> => {
|
||||
return {
|
||||
onClick: () => {
|
||||
setSelectedValue?.(date);
|
||||
if (closeDialogOnSelection) {
|
||||
setCalendarIsOpen?.(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
[setSelectedValue, setCalendarIsOpen, closeDialogOnSelection],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedValue: internalValue,
|
||||
setSelectedValue: setInternalValue,
|
||||
calendarIsOpen,
|
||||
setCalendarIsOpen,
|
||||
dayIsActive,
|
||||
dayIsHighlighted: () => false,
|
||||
dayIsRangeStart: () => false,
|
||||
dayIsRangeEnd: () => false,
|
||||
getCellProps,
|
||||
calendarDates,
|
||||
setCalendarDates,
|
||||
isPlaceholder,
|
||||
clear,
|
||||
setIsPlaceholder,
|
||||
min,
|
||||
max,
|
||||
granularity,
|
||||
timezone,
|
||||
closeDialogOnSelection,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, {ComponentPropsWithoutRef, FocusEventHandler, Ref} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {createFocusManager} from '@react-aria/focus';
|
||||
import {mergeProps, useObjectRef} from '@react-aria/utils';
|
||||
import {getInputFieldClassNames} from '../../get-input-field-class-names';
|
||||
import {Field, FieldProps} from '../../field';
|
||||
import {Input} from '../../input';
|
||||
import {useField} from '../../use-field';
|
||||
|
||||
export interface DatePickerFieldProps
|
||||
extends Omit<FieldProps, 'fieldClassNames'> {
|
||||
inputRef?: Ref<HTMLDivElement>;
|
||||
onBlur?: FocusEventHandler;
|
||||
showCalendarFooter?: boolean;
|
||||
}
|
||||
export const DatePickerField = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
DatePickerFieldProps
|
||||
>(({inputRef, wrapperProps, children, onBlur, ...other}, ref) => {
|
||||
const fieldClassNames = getInputFieldClassNames(other);
|
||||
const objRef = useObjectRef(ref);
|
||||
|
||||
const {fieldProps, inputProps} = useField({
|
||||
...other,
|
||||
focusRef: objRef,
|
||||
labelElementType: 'span',
|
||||
});
|
||||
|
||||
fieldClassNames.wrapper = clsx(
|
||||
fieldClassNames.wrapper,
|
||||
other.disabled && 'pointer-events-none',
|
||||
);
|
||||
|
||||
return (
|
||||
<Field
|
||||
wrapperProps={mergeProps<ComponentPropsWithoutRef<'div'>[]>(
|
||||
wrapperProps!,
|
||||
{
|
||||
onBlur: e => {
|
||||
if (!objRef.current.contains(e.relatedTarget)) {
|
||||
onBlur?.(e);
|
||||
}
|
||||
},
|
||||
onClick: () => {
|
||||
// focus first segment when clicking on label or somewhere else in the field, but no directly on segment
|
||||
const focusManager = createFocusManager(objRef);
|
||||
focusManager?.focusFirst();
|
||||
},
|
||||
},
|
||||
)}
|
||||
fieldClassNames={fieldClassNames}
|
||||
ref={objRef}
|
||||
{...fieldProps}
|
||||
>
|
||||
<Input
|
||||
inputProps={inputProps}
|
||||
className={clsx(fieldClassNames.input, 'gap-10')}
|
||||
ref={inputRef}
|
||||
>
|
||||
{children}
|
||||
</Input>
|
||||
</Field>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, {
|
||||
ComponentPropsWithoutRef,
|
||||
Fragment,
|
||||
MouseEvent,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {DateRangeIcon} from '../../../../../icons/material/DateRange';
|
||||
import {DialogTrigger} from '../../../../overlays/dialog/dialog-trigger';
|
||||
import {DatePickerField, DatePickerFieldProps} from './date-picker-field';
|
||||
import {useDateRangePickerState} from './use-date-range-picker-state';
|
||||
import {ArrowRightAltIcon} from '../../../../../icons/material/ArrowRightAlt';
|
||||
import {DatePickerValueProps} from '../date-picker/use-date-picker-state';
|
||||
import {DateRangeValue} from './date-range-value';
|
||||
import {DateSegmentList} from '../segments/date-segment-list';
|
||||
import {DateRangeDialog} from './dialog/date-range-dialog';
|
||||
import {useIsMobileMediaQuery} from '../../../../../utils/hooks/is-mobile-media-query';
|
||||
|
||||
export interface DateRangePickerProps
|
||||
extends DatePickerValueProps<Partial<DateRangeValue>>,
|
||||
Omit<DatePickerFieldProps, 'children'> {}
|
||||
export function DateRangePicker(props: DateRangePickerProps) {
|
||||
const {granularity, closeDialogOnSelection, ...fieldProps} = props;
|
||||
const state = useDateRangePickerState(props);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const hideCalendarIcon = isMobile && granularity !== 'day';
|
||||
|
||||
const dialog = (
|
||||
<DialogTrigger
|
||||
offset={8}
|
||||
placement="bottom-start"
|
||||
isOpen={state.calendarIsOpen}
|
||||
onOpenChange={state.setCalendarIsOpen}
|
||||
type="popover"
|
||||
triggerRef={inputRef}
|
||||
returnFocusToTrigger={false}
|
||||
moveFocusToDialog={false}
|
||||
>
|
||||
<DateRangeDialog state={state} />
|
||||
</DialogTrigger>
|
||||
);
|
||||
|
||||
const openOnClick: ComponentPropsWithoutRef<'div'> = {
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isHourSegment(e)) {
|
||||
state.setCalendarIsOpen(true);
|
||||
} else {
|
||||
state.setCalendarIsOpen(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const value = state.selectedValue;
|
||||
const onChange = state.setSelectedValue;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<DatePickerField
|
||||
ref={inputRef}
|
||||
wrapperProps={openOnClick}
|
||||
endAdornment={!hideCalendarIcon ? <DateRangeIcon /> : undefined}
|
||||
{...fieldProps}
|
||||
>
|
||||
<DateSegmentList
|
||||
isPlaceholder={state.isPlaceholder?.start}
|
||||
state={state}
|
||||
segmentProps={openOnClick}
|
||||
value={value.start}
|
||||
onChange={newValue => {
|
||||
onChange({start: newValue, end: value.end});
|
||||
}}
|
||||
/>
|
||||
<ArrowRightAltIcon
|
||||
className="block flex-shrink-0 text-muted"
|
||||
size="md"
|
||||
/>
|
||||
<DateSegmentList
|
||||
isPlaceholder={state.isPlaceholder?.end}
|
||||
state={state}
|
||||
segmentProps={openOnClick}
|
||||
value={value.end}
|
||||
onChange={newValue => {
|
||||
onChange({start: value.start, end: newValue});
|
||||
}}
|
||||
/>
|
||||
</DatePickerField>
|
||||
{dialog}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function isHourSegment(e: MouseEvent<HTMLDivElement>): boolean {
|
||||
return ['hour', 'minute', 'dayPeriod'].includes(
|
||||
(e.currentTarget as HTMLElement).ariaLabel || ''
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {ZonedDateTime} from '@internationalized/date';
|
||||
|
||||
export type DateRangeValue = {
|
||||
start: ZonedDateTime;
|
||||
end: ZonedDateTime;
|
||||
preset?: number;
|
||||
compareStart?: ZonedDateTime;
|
||||
compareEnd?: ZonedDateTime;
|
||||
comparePreset?: number;
|
||||
};
|
||||
|
||||
export function dateRangeValueToPayload(value: {
|
||||
dateRange?: DateRangeValue;
|
||||
[key: string]: any;
|
||||
}) {
|
||||
const payload = {
|
||||
...value,
|
||||
};
|
||||
if (payload.dateRange) {
|
||||
payload.startDate = payload.dateRange.start.toAbsoluteString();
|
||||
payload.endDate = payload.dateRange.end.toAbsoluteString();
|
||||
payload.compareStartDate =
|
||||
payload.dateRange.compareStart?.toAbsoluteString();
|
||||
payload.compareEndDate = payload.dateRange.compareEnd?.toAbsoluteString();
|
||||
payload.timezone = payload.dateRange.start.timeZone;
|
||||
delete payload.dateRange;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {List, ListItem} from '@common/ui/list/list';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
|
||||
import {DateRangeComparePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-compare-presets';
|
||||
|
||||
interface DateRangePresetList {
|
||||
originalRangeValue: DateRangeValue;
|
||||
onPresetSelected: (value: DateRangeValue) => void;
|
||||
selectedValue?: DateRangeValue | null;
|
||||
}
|
||||
export function DateRangeComparePresetList({
|
||||
originalRangeValue,
|
||||
onPresetSelected,
|
||||
selectedValue,
|
||||
}: DateRangePresetList) {
|
||||
return (
|
||||
<List>
|
||||
{DateRangeComparePresets.map(preset => (
|
||||
<ListItem
|
||||
borderRadius="rounded-none"
|
||||
capitalizeFirst
|
||||
key={preset.key}
|
||||
isSelected={selectedValue?.preset === preset.key}
|
||||
onSelected={() => {
|
||||
const newValue = preset.getRangeValue(originalRangeValue);
|
||||
onPresetSelected(newValue);
|
||||
}}
|
||||
>
|
||||
<Trans {...preset.label} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {message} from '@common/i18n/message';
|
||||
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
|
||||
import {MessageDescriptor} from '@common/i18n/message-descriptor';
|
||||
|
||||
export interface DateRangeComparePreset {
|
||||
key: number;
|
||||
label: MessageDescriptor;
|
||||
getRangeValue: (range: DateRangeValue) => DateRangeValue;
|
||||
}
|
||||
|
||||
export const DateRangeComparePresets: DateRangeComparePreset[] = [
|
||||
{
|
||||
key: 0,
|
||||
label: message('Preceding period'),
|
||||
getRangeValue: (range: DateRangeValue) => {
|
||||
const startDate = range.start;
|
||||
const endDate = range.end;
|
||||
|
||||
const diffInMilliseconds =
|
||||
endDate.toDate().getTime() - startDate.toDate().getTime();
|
||||
const diffInMinutes = diffInMilliseconds / (1000 * 60);
|
||||
const newStart = startDate.subtract({minutes: diffInMinutes});
|
||||
return {
|
||||
preset: 0,
|
||||
start: newStart,
|
||||
end: startDate,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
label: message('Same period last year'),
|
||||
getRangeValue: (range: DateRangeValue) => {
|
||||
return {
|
||||
start: range.start.subtract({years: 1}),
|
||||
end: range.end.subtract({years: 1}),
|
||||
preset: 1,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
label: message('Custom'),
|
||||
getRangeValue: (range: DateRangeValue) => {
|
||||
return {
|
||||
start: range.start.subtract({weeks: 1}),
|
||||
end: range.end.subtract({weeks: 1}),
|
||||
preset: 2,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, {Fragment, ReactNode, useRef, useState} from 'react';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {DatePickerField} from '../date-picker-field';
|
||||
import {DateRangePickerState} from '../use-date-range-picker-state';
|
||||
import {Calendar} from '../../calendar/calendar';
|
||||
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {ArrowRightAltIcon} from '@common/icons/material/ArrowRightAlt';
|
||||
import {DateSegmentList} from '../../segments/date-segment-list';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {FormattedDateTimeRange} from '@common/i18n/formatted-date-time-range';
|
||||
import {DatePresetList} from './date-range-preset-list';
|
||||
import {useIsTabletMediaQuery} from '@common/utils/hooks/is-tablet-media-query';
|
||||
import {Switch} from '@common/ui/forms/toggle/switch';
|
||||
import {DateRangeComparePresetList} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-compare-preset-list';
|
||||
|
||||
interface DateRangeDialogProps {
|
||||
state: DateRangePickerState;
|
||||
compareState?: DateRangePickerState;
|
||||
compareVisibleDefault?: boolean;
|
||||
showInlineDatePickerField?: boolean;
|
||||
}
|
||||
export function DateRangeDialog({
|
||||
state,
|
||||
compareState,
|
||||
showInlineDatePickerField = false,
|
||||
compareVisibleDefault = false,
|
||||
}: DateRangeDialogProps) {
|
||||
const isTablet = useIsTabletMediaQuery();
|
||||
const {close} = useDialogContext();
|
||||
const initialStateRef = useRef<DateRangePickerState>(state);
|
||||
const hasPlaceholder = state.isPlaceholder.start || state.isPlaceholder.end;
|
||||
const [compareVisible, setCompareVisible] = useState(compareVisibleDefault);
|
||||
|
||||
const footer = (
|
||||
<DialogFooter
|
||||
dividerTop
|
||||
startAction={
|
||||
!hasPlaceholder && !isTablet ? (
|
||||
<div className="text-xs">
|
||||
<FormattedDateTimeRange
|
||||
start={state.selectedValue.start.toDate()}
|
||||
end={state.selectedValue.end.toDate()}
|
||||
options={{dateStyle: 'medium'}}
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
state.setSelectedValue(initialStateRef.current.selectedValue);
|
||||
state.setIsPlaceholder(initialStateRef.current.isPlaceholder);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const value = state.selectedValue;
|
||||
if (compareState && compareVisible) {
|
||||
value.compareStart = compareState.selectedValue.start;
|
||||
value.compareEnd = compareState.selectedValue.end;
|
||||
}
|
||||
close(value);
|
||||
}}
|
||||
>
|
||||
<Trans message="Select" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog size="auto">
|
||||
<DialogBody className="flex" padding="p-0">
|
||||
{!isTablet && (
|
||||
<div className="min-w-192 py-14">
|
||||
<DatePresetList
|
||||
selectedValue={state.selectedValue}
|
||||
onPresetSelected={preset => {
|
||||
state.setSelectedValue(preset);
|
||||
if (state.closeDialogOnSelection) {
|
||||
close(preset);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!!compareState && (
|
||||
<Fragment>
|
||||
<Switch
|
||||
className="mx-20 mb-10 mt-14"
|
||||
checked={compareVisible}
|
||||
onChange={e => setCompareVisible(e.target.checked)}
|
||||
>
|
||||
<Trans message="Compare" />
|
||||
</Switch>
|
||||
{compareVisible && (
|
||||
<DateRangeComparePresetList
|
||||
originalRangeValue={state.selectedValue}
|
||||
selectedValue={compareState.selectedValue}
|
||||
onPresetSelected={preset => {
|
||||
compareState.setSelectedValue(preset);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence initial={false}>
|
||||
<Calendars
|
||||
state={state}
|
||||
compareState={compareState}
|
||||
showInlineDatePickerField={showInlineDatePickerField}
|
||||
compareVisible={compareVisible}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
</DialogBody>
|
||||
{!state.closeDialogOnSelection && footer}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface CustomRangePanelProps {
|
||||
state: DateRangePickerState;
|
||||
compareState?: DateRangePickerState;
|
||||
showInlineDatePickerField?: boolean;
|
||||
compareVisible: boolean;
|
||||
}
|
||||
function Calendars({
|
||||
state,
|
||||
compareState,
|
||||
showInlineDatePickerField,
|
||||
compareVisible,
|
||||
}: CustomRangePanelProps) {
|
||||
return (
|
||||
<m.div
|
||||
initial={{width: 0, overflow: 'hidden'}}
|
||||
animate={{width: 'auto'}}
|
||||
exit={{width: 0, overflow: 'hidden'}}
|
||||
transition={{type: 'tween', duration: 0.125}}
|
||||
className="border-l px-20 pb-20 pt-10"
|
||||
>
|
||||
{showInlineDatePickerField && (
|
||||
<div>
|
||||
<InlineDatePickerField state={state} />
|
||||
{!!compareState && compareVisible && (
|
||||
<InlineDatePickerField
|
||||
state={compareState}
|
||||
label={<Trans message="Compare" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-36">
|
||||
<Calendar state={state} visibleMonths={2} />
|
||||
</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InlineDatePickerFieldProps {
|
||||
state: DateRangePickerState;
|
||||
label?: ReactNode;
|
||||
}
|
||||
function InlineDatePickerField({state, label}: InlineDatePickerFieldProps) {
|
||||
const {selectedValue, setSelectedValue} = state;
|
||||
return (
|
||||
<DatePickerField className="mb-20 mt-10" label={label}>
|
||||
<DateSegmentList
|
||||
state={state}
|
||||
value={selectedValue.start}
|
||||
onChange={newValue => {
|
||||
setSelectedValue({...selectedValue, start: newValue});
|
||||
}}
|
||||
/>
|
||||
<ArrowRightAltIcon className="block flex-shrink-0 text-muted" size="md" />
|
||||
<DateSegmentList
|
||||
state={state}
|
||||
value={selectedValue.end}
|
||||
onChange={newValue => {
|
||||
setSelectedValue({...selectedValue, end: newValue});
|
||||
}}
|
||||
/>
|
||||
</DatePickerField>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {List, ListItem} from '@common/ui/list/list';
|
||||
import {DateRangePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-presets';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
|
||||
|
||||
interface DateRangePresetList {
|
||||
onPresetSelected: (value: DateRangeValue) => void;
|
||||
selectedValue?: DateRangeValue | null;
|
||||
}
|
||||
export function DatePresetList({
|
||||
onPresetSelected,
|
||||
selectedValue,
|
||||
}: DateRangePresetList) {
|
||||
return (
|
||||
<List>
|
||||
{DateRangePresets.map(preset => (
|
||||
<ListItem
|
||||
borderRadius="rounded-none"
|
||||
capitalizeFirst
|
||||
key={preset.key}
|
||||
isSelected={selectedValue?.preset === preset.key}
|
||||
onSelected={() => {
|
||||
const newValue = preset.getRangeValue();
|
||||
onPresetSelected(newValue);
|
||||
}}
|
||||
>
|
||||
<Trans {...preset.label} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {DateRangeValue} from '../date-range-value';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {MessageDescriptor} from '@common/i18n/message-descriptor';
|
||||
import {
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
endOfYear,
|
||||
now,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
startOfYear,
|
||||
} from '@internationalized/date';
|
||||
import {startOfDay} from '@common/utils/date/start-of-day';
|
||||
import {endOfDay} from '@common/utils/date/end-of-day';
|
||||
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
|
||||
import {getUserTimezone} from '@common/i18n/get-user-timezone';
|
||||
|
||||
const Now = startOfDay(now(getUserTimezone()));
|
||||
const locale = getBootstrapData()?.i18n?.language || 'en';
|
||||
|
||||
export interface DateRangePreset {
|
||||
key: number;
|
||||
label: MessageDescriptor;
|
||||
getRangeValue: () => DateRangeValue;
|
||||
}
|
||||
|
||||
export const DateRangePresets: DateRangePreset[] = [
|
||||
{
|
||||
key: 0,
|
||||
label: message('Today'),
|
||||
getRangeValue: () => ({
|
||||
preset: 0,
|
||||
start: Now,
|
||||
end: endOfDay(Now),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
label: message('Yesterday'),
|
||||
getRangeValue: () => ({
|
||||
preset: 1,
|
||||
start: Now.subtract({days: 1}),
|
||||
end: endOfDay(Now).subtract({days: 1}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
label: message('This week'),
|
||||
getRangeValue: () => ({
|
||||
preset: 2,
|
||||
start: startOfWeek(Now, locale),
|
||||
end: endOfWeek(endOfDay(Now), locale),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
label: message('Last week'),
|
||||
getRangeValue: () => {
|
||||
const start = startOfWeek(Now, locale).subtract({days: 7});
|
||||
return {
|
||||
preset: 3,
|
||||
start,
|
||||
end: start.add({days: 6}),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 4,
|
||||
label: message('Last 7 days'),
|
||||
getRangeValue: () => ({
|
||||
preset: 4,
|
||||
start: Now.subtract({days: 7}),
|
||||
end: endOfDay(Now),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 6,
|
||||
label: message('Last 30 days'),
|
||||
getRangeValue: () => ({
|
||||
preset: 6,
|
||||
start: Now.subtract({days: 30}),
|
||||
end: endOfDay(Now),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 7,
|
||||
label: message('Last 3 months'),
|
||||
getRangeValue: () => ({
|
||||
preset: 7,
|
||||
start: Now.subtract({months: 3}),
|
||||
end: endOfDay(Now),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 8,
|
||||
label: message('Last 12 months'),
|
||||
getRangeValue: () => ({
|
||||
preset: 8,
|
||||
start: Now.subtract({months: 12}),
|
||||
end: endOfDay(Now),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 9,
|
||||
label: message('This month'),
|
||||
getRangeValue: () => ({
|
||||
preset: 9,
|
||||
start: startOfMonth(Now),
|
||||
end: endOfMonth(endOfDay(Now)),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 10,
|
||||
label: message('This year'),
|
||||
getRangeValue: () => ({
|
||||
preset: 10,
|
||||
start: startOfYear(Now),
|
||||
end: endOfYear(endOfDay(Now)),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 11,
|
||||
label: message('Last year'),
|
||||
getRangeValue: () => ({
|
||||
preset: 11,
|
||||
start: startOfYear(Now).subtract({years: 1}),
|
||||
end: endOfYear(endOfDay(Now)).subtract({years: 1}),
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,77 @@
|
||||
import {parseAbsoluteToLocal, ZonedDateTime} from '@internationalized/date';
|
||||
import {DateRangeValue} from './date-range-value';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import React from 'react';
|
||||
import {DateRangePicker, DateRangePickerProps} from './date-range-picker';
|
||||
|
||||
export interface AbsoluteDateRange {
|
||||
start?: string;
|
||||
end?: string;
|
||||
preset?: number;
|
||||
}
|
||||
|
||||
interface FormDateRange {
|
||||
start?: string | ZonedDateTime;
|
||||
end?: string | ZonedDateTime;
|
||||
preset?: number;
|
||||
}
|
||||
|
||||
export interface FormDateRangePickerProps extends DateRangePickerProps {
|
||||
name: string;
|
||||
}
|
||||
export function FormDateRangePicker(props: FormDateRangePickerProps) {
|
||||
const {
|
||||
field: {onChange, onBlur, value, ref},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
});
|
||||
|
||||
const formProps: Partial<DateRangePickerProps> = {
|
||||
onChange: e => {
|
||||
onChange(e ? dateRangeToAbsoluteRange(e) : null);
|
||||
},
|
||||
onBlur,
|
||||
value: absoluteRangeToDateRange(value),
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
inputRef: ref,
|
||||
};
|
||||
|
||||
return <DateRangePicker {...mergeProps(formProps, props)} />;
|
||||
}
|
||||
|
||||
export function absoluteRangeToDateRange(props: FormDateRange | null) {
|
||||
const {start, end, preset} = props || {};
|
||||
const dateRange: Partial<DateRangeValue> = {preset};
|
||||
try {
|
||||
if (start) {
|
||||
dateRange.start =
|
||||
typeof start === 'string' ? parseAbsoluteToLocal(start) : start;
|
||||
}
|
||||
if (end) {
|
||||
dateRange.end = typeof end === 'string' ? parseAbsoluteToLocal(end) : end;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return dateRange;
|
||||
}
|
||||
|
||||
export function dateRangeToAbsoluteRange({
|
||||
start,
|
||||
end,
|
||||
preset,
|
||||
}: Partial<DateRangeValue> = {}): AbsoluteDateRange {
|
||||
const absoluteRange: AbsoluteDateRange = {
|
||||
preset,
|
||||
};
|
||||
if (start) {
|
||||
absoluteRange.start = start.toAbsoluteString();
|
||||
}
|
||||
if (end) {
|
||||
absoluteRange.end = end.toAbsoluteString();
|
||||
}
|
||||
return absoluteRange;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import {HTMLAttributes, useCallback, useState} from 'react';
|
||||
import {
|
||||
CalendarDate,
|
||||
DateValue,
|
||||
endOfMonth,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
maxDate,
|
||||
minDate,
|
||||
startOfMonth,
|
||||
toCalendarDate,
|
||||
toZoned,
|
||||
ZonedDateTime,
|
||||
} from '@internationalized/date';
|
||||
import {
|
||||
BaseDatePickerState,
|
||||
DatePickerValueProps,
|
||||
} from '../date-picker/use-date-picker-state';
|
||||
import {DateRangeValue} from './date-range-value';
|
||||
import {useBaseDatePickerState} from '../use-base-date-picker-state';
|
||||
import {startOfDay} from '@common/utils/date/start-of-day';
|
||||
import {endOfDay} from '@common/utils/date/end-of-day';
|
||||
import {useCurrentDateTime} from '@common/i18n/use-current-date-time';
|
||||
|
||||
export interface IsPlaceholderValue {
|
||||
start: boolean;
|
||||
end: boolean;
|
||||
}
|
||||
|
||||
export type DateRangePickerState = BaseDatePickerState<
|
||||
DateRangeValue,
|
||||
IsPlaceholderValue
|
||||
>;
|
||||
|
||||
export function useDateRangePickerState(
|
||||
props: DatePickerValueProps<Partial<DateRangeValue>, DateRangeValue>,
|
||||
): DateRangePickerState {
|
||||
const now = useCurrentDateTime();
|
||||
const [isPlaceholder, setIsPlaceholder] = useState<IsPlaceholderValue>({
|
||||
start: (!props.value || !props.value.start) && !props.defaultValue?.start,
|
||||
end: (!props.value || !props.value.end) && !props.defaultValue?.end,
|
||||
});
|
||||
|
||||
// if user clears the date, we will want to still keep an
|
||||
// instance internally, but return null via "onChange" callback
|
||||
const setStateValue = props.onChange;
|
||||
const [internalValue, setInternalValue] = useControlledState(
|
||||
props.value ? completeRange(props.value, now) : undefined,
|
||||
!props.value ? completeRange(props.defaultValue, now) : undefined,
|
||||
value => {
|
||||
setIsPlaceholder({start: false, end: false});
|
||||
setStateValue?.(value);
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
granularity,
|
||||
timezone,
|
||||
calendarIsOpen,
|
||||
setCalendarIsOpen,
|
||||
closeDialogOnSelection,
|
||||
} = useBaseDatePickerState(internalValue.start, props);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setIsPlaceholder({start: true, end: true});
|
||||
setInternalValue(completeRange(null, now));
|
||||
setStateValue?.(null);
|
||||
setCalendarIsOpen(false);
|
||||
}, [now, setInternalValue, setStateValue, setCalendarIsOpen]);
|
||||
|
||||
const [anchorDate, setAnchorDate] = useState<CalendarDate | null>(null);
|
||||
const [isHighlighting, setIsHighlighting] = useState(false);
|
||||
const [highlightedRange, setHighlightedRange] =
|
||||
useState<DateRangeValue>(internalValue);
|
||||
const [calendarDates, setCalendarDates] = useState<CalendarDate[]>(() => {
|
||||
return rangeToCalendarDates(internalValue, max);
|
||||
});
|
||||
|
||||
const constrainRange = useCallback(
|
||||
(range: DateRangeValue): DateRangeValue => {
|
||||
let start = range.start;
|
||||
let end = range.end;
|
||||
|
||||
// make sure start date is after min date and before max date/range end
|
||||
if (min) {
|
||||
start = maxDate(start, min);
|
||||
}
|
||||
const startMax = max ? minDate(max, end) : end;
|
||||
start = minDate(start, startMax);
|
||||
|
||||
// make sure end date is after min date/range start and before max date
|
||||
const endMin = min ? maxDate(min, start) : start;
|
||||
end = maxDate(end, endMin);
|
||||
|
||||
if (max) {
|
||||
end = minDate(end, max);
|
||||
}
|
||||
|
||||
return {start: toZoned(start, timezone), end: toZoned(end, timezone)};
|
||||
},
|
||||
[min, max, timezone],
|
||||
);
|
||||
|
||||
const setSelectedValue = useCallback(
|
||||
(newRange: DateRangeValue) => {
|
||||
const value = {
|
||||
...constrainRange(newRange),
|
||||
preset: newRange.preset,
|
||||
};
|
||||
setInternalValue(value);
|
||||
setHighlightedRange(value);
|
||||
setCalendarDates(rangeToCalendarDates(value, max));
|
||||
setIsPlaceholder({start: false, end: false});
|
||||
},
|
||||
[setInternalValue, constrainRange, max],
|
||||
);
|
||||
|
||||
const dayIsActive = useCallback(
|
||||
(day: CalendarDate) => {
|
||||
return (
|
||||
(!isPlaceholder.start && isSameDay(day, highlightedRange.start)) ||
|
||||
(!isPlaceholder.end && isSameDay(day, highlightedRange.end))
|
||||
);
|
||||
},
|
||||
[highlightedRange, isPlaceholder],
|
||||
);
|
||||
|
||||
const dayIsHighlighted = useCallback(
|
||||
(day: CalendarDate) => {
|
||||
return (
|
||||
(isHighlighting || (!isPlaceholder.start && !isPlaceholder.end)) &&
|
||||
day.compare(highlightedRange.start) >= 0 &&
|
||||
day.compare(highlightedRange.end) <= 0
|
||||
);
|
||||
},
|
||||
[highlightedRange, isPlaceholder, isHighlighting],
|
||||
);
|
||||
|
||||
const dayIsRangeStart = useCallback(
|
||||
(day: CalendarDate) => isSameDay(day, highlightedRange.start),
|
||||
[highlightedRange],
|
||||
);
|
||||
|
||||
const dayIsRangeEnd = useCallback(
|
||||
(day: CalendarDate) => isSameDay(day, highlightedRange.end),
|
||||
[highlightedRange],
|
||||
);
|
||||
|
||||
const getCellProps = useCallback(
|
||||
(date: CalendarDate, isSameMonth: boolean): HTMLAttributes<HTMLElement> => {
|
||||
return {
|
||||
onPointerEnter: () => {
|
||||
if (isHighlighting && isSameMonth) {
|
||||
setHighlightedRange(
|
||||
makeRange({start: anchorDate!, end: date, timezone}),
|
||||
);
|
||||
}
|
||||
},
|
||||
onClick: () => {
|
||||
if (!isHighlighting) {
|
||||
setIsHighlighting(true);
|
||||
setAnchorDate(date);
|
||||
setHighlightedRange(makeRange({start: date, end: date, timezone}));
|
||||
} else {
|
||||
const finalRange = makeRange({
|
||||
start: anchorDate!,
|
||||
end: date,
|
||||
timezone,
|
||||
});
|
||||
// cast to start and end of day after making range, because "makeRange"
|
||||
// will flip start and end dates, if they are out of order
|
||||
finalRange.start = startOfDay(finalRange.start);
|
||||
finalRange.end = endOfDay(finalRange.end);
|
||||
setIsHighlighting(false);
|
||||
setAnchorDate(null);
|
||||
setSelectedValue?.(finalRange);
|
||||
if (closeDialogOnSelection) {
|
||||
setCalendarIsOpen?.(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
[
|
||||
anchorDate,
|
||||
isHighlighting,
|
||||
setSelectedValue,
|
||||
setCalendarIsOpen,
|
||||
closeDialogOnSelection,
|
||||
timezone,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedValue: internalValue,
|
||||
setSelectedValue,
|
||||
calendarIsOpen,
|
||||
setCalendarIsOpen,
|
||||
dayIsActive,
|
||||
dayIsHighlighted,
|
||||
dayIsRangeStart,
|
||||
dayIsRangeEnd,
|
||||
getCellProps,
|
||||
calendarDates,
|
||||
setIsPlaceholder,
|
||||
isPlaceholder,
|
||||
clear,
|
||||
setCalendarDates,
|
||||
min,
|
||||
max,
|
||||
granularity,
|
||||
timezone,
|
||||
closeDialogOnSelection,
|
||||
};
|
||||
}
|
||||
|
||||
function rangeToCalendarDates(
|
||||
range: DateRangeValue,
|
||||
max?: DateValue,
|
||||
): CalendarDate[] {
|
||||
let start = toCalendarDate(startOfMonth(range.start));
|
||||
let end = toCalendarDate(endOfMonth(range.end));
|
||||
|
||||
// make sure we don't show the same month twice
|
||||
if (isSameMonth(start, end)) {
|
||||
end = endOfMonth(end.add({months: 1}));
|
||||
}
|
||||
|
||||
// if next month is disabled, show previous instead
|
||||
if (max && end.compare(max) > 0) {
|
||||
end = start;
|
||||
start = startOfMonth(start.subtract({months: 1}));
|
||||
}
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
interface MakeRangeProps {
|
||||
start: DateValue;
|
||||
end: DateValue;
|
||||
timezone: string;
|
||||
}
|
||||
function makeRange(props: MakeRangeProps): DateRangeValue {
|
||||
const start = toZoned(props.start, props.timezone);
|
||||
const end = toZoned(props.end, props.timezone);
|
||||
if (start.compare(end) > 0) {
|
||||
return {start: end, end: start};
|
||||
}
|
||||
return {start, end};
|
||||
}
|
||||
|
||||
function completeRange(
|
||||
range: Partial<DateRangeValue> | null | undefined,
|
||||
now: ZonedDateTime,
|
||||
): DateRangeValue {
|
||||
if (range?.start && range?.end) {
|
||||
return range as DateRangeValue;
|
||||
} else if (!range?.start && range?.end) {
|
||||
range.start = range.end.subtract({months: 1});
|
||||
return range as DateRangeValue;
|
||||
} else if (!range?.end && range?.start) {
|
||||
range.end = range.start.add({months: 1});
|
||||
return range as DateRangeValue;
|
||||
}
|
||||
return {start: now, end: now.add({months: 1})};
|
||||
}
|
||||
1
common/resources/client/ui/forms/input-field/date/granularity.ts
Executable file
1
common/resources/client/ui/forms/input-field/date/granularity.ts
Executable file
@@ -0,0 +1 @@
|
||||
export type Granularity = 'day' | 'hour' | 'minute';
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, {ComponentPropsWithoutRef, useMemo} from 'react';
|
||||
import {ZonedDateTime} from '@internationalized/date';
|
||||
import {EditableDateSegment, EditableSegment} from './editable-date-segment';
|
||||
import {LiteralDateSegment, LiteralSegment} from './literal-segment';
|
||||
import {useDateFormatter} from '@common/i18n/use-date-formatter';
|
||||
import {DatePickerState} from '../date-picker/use-date-picker-state';
|
||||
import {getSegmentLimits} from './utils/get-segment-limits';
|
||||
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
|
||||
|
||||
interface DateSegmentListProps {
|
||||
segmentProps?: ComponentPropsWithoutRef<'div'>;
|
||||
state: DatePickerState | DateRangePickerState;
|
||||
value: ZonedDateTime;
|
||||
onChange: (newValue: ZonedDateTime) => void;
|
||||
isPlaceholder?: boolean;
|
||||
}
|
||||
export function DateSegmentList({
|
||||
segmentProps,
|
||||
state,
|
||||
value,
|
||||
onChange,
|
||||
isPlaceholder,
|
||||
}: DateSegmentListProps) {
|
||||
const {granularity} = state;
|
||||
const options = useMemo(() => {
|
||||
const memoOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
};
|
||||
if (granularity === 'minute') {
|
||||
memoOptions.hour = 'numeric';
|
||||
memoOptions.minute = 'numeric';
|
||||
}
|
||||
return memoOptions;
|
||||
}, [granularity]);
|
||||
|
||||
const formatter = useDateFormatter(options);
|
||||
|
||||
const dateValue = useMemo(() => value.toDate(), [value]);
|
||||
const segments = useMemo(() => {
|
||||
return formatter.formatToParts(dateValue).map(segment => {
|
||||
const limits = getSegmentLimits(
|
||||
value,
|
||||
segment.type,
|
||||
formatter.resolvedOptions(),
|
||||
);
|
||||
const textValue =
|
||||
isPlaceholder && segment.type !== 'literal'
|
||||
? limits.placeholder
|
||||
: segment.value;
|
||||
return {
|
||||
type: segment.type,
|
||||
text: segment.value === ', ' ? ' ' : textValue,
|
||||
...limits,
|
||||
minLength:
|
||||
segment.type !== 'literal' ? String(limits.maxValue).length : 1,
|
||||
} as LiteralSegment | EditableSegment;
|
||||
});
|
||||
}, [dateValue, formatter, isPlaceholder, value]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === 'literal') {
|
||||
return (
|
||||
<LiteralDateSegment
|
||||
domProps={segmentProps}
|
||||
key={index}
|
||||
segment={segment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EditableDateSegment
|
||||
isPlaceholder={isPlaceholder}
|
||||
domProps={segmentProps}
|
||||
state={state}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
segment={segment}
|
||||
key={index}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import {useFocusManager} from '@react-aria/focus';
|
||||
import React, {
|
||||
ComponentPropsWithoutRef,
|
||||
HTMLAttributes,
|
||||
KeyboardEventHandler,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {NumberParser} from '@internationalized/number';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import {today, ZonedDateTime} from '@internationalized/date';
|
||||
import {useSelectedLocale} from '@common/i18n/selected-locale';
|
||||
import {useDateFormatter} from '@common/i18n/use-date-formatter';
|
||||
import {DatePickerState} from '../date-picker/use-date-picker-state';
|
||||
import {adjustSegment} from './utils/adjust-segment';
|
||||
import {setSegment} from './utils/set-segment';
|
||||
import {PAGE_STEP} from './utils/page-step';
|
||||
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
|
||||
export interface EditableSegment {
|
||||
type: 'day' | 'dayPeriod' | 'hour' | 'minute' | 'month' | 'second' | 'year';
|
||||
text: string;
|
||||
value: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
minLength: number;
|
||||
}
|
||||
|
||||
interface DatePickerSegmentProps {
|
||||
segment: EditableSegment;
|
||||
domProps?: ComponentPropsWithoutRef<'div'>;
|
||||
state: DatePickerState | DateRangePickerState;
|
||||
value: ZonedDateTime;
|
||||
onChange: (newValue: ZonedDateTime) => void;
|
||||
isPlaceholder?: boolean;
|
||||
}
|
||||
export function EditableDateSegment({
|
||||
segment,
|
||||
domProps,
|
||||
value,
|
||||
onChange,
|
||||
isPlaceholder,
|
||||
state: {timezone, calendarIsOpen, setCalendarIsOpen},
|
||||
}: DatePickerSegmentProps) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const enteredKeys = useRef('');
|
||||
const {localeCode} = useSelectedLocale();
|
||||
const focusManager = useFocusManager();
|
||||
const formatter = useDateFormatter({timeZone: timezone});
|
||||
const parser = useMemo(
|
||||
() => new NumberParser(localeCode, {maximumFractionDigits: 0}),
|
||||
[localeCode],
|
||||
);
|
||||
|
||||
const setSegmentValue = (newValue: number) => {
|
||||
onChange(
|
||||
setSegment(value, segment.type, newValue, formatter.resolvedOptions()),
|
||||
);
|
||||
};
|
||||
|
||||
const adjustSegmentValue = (amount: number) => {
|
||||
onChange(
|
||||
adjustSegment(value, segment.type, amount, formatter.resolvedOptions()),
|
||||
);
|
||||
};
|
||||
|
||||
const backspace = () => {
|
||||
if (parser.isValidPartialNumber(segment.text)) {
|
||||
const newValue = segment.text.slice(0, -1);
|
||||
const parsed = parser.parse(newValue);
|
||||
if (newValue.length === 0 || parsed === 0) {
|
||||
const now = today(timezone);
|
||||
if (segment.type in now) {
|
||||
// @ts-ignore
|
||||
setSegmentValue(now[segment.type]);
|
||||
}
|
||||
} else {
|
||||
setSegmentValue(parsed);
|
||||
}
|
||||
enteredKeys.current = newValue;
|
||||
} else if (segment.type === 'dayPeriod') {
|
||||
adjustSegmentValue(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown: KeyboardEventHandler = e => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation between date segments and deletion
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
focusManager?.focusPrevious();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
focusManager?.focusNext();
|
||||
break;
|
||||
case 'Enter':
|
||||
(e.target as HTMLElement).closest('form')?.requestSubmit();
|
||||
setCalendarIsOpen(!calendarIsOpen);
|
||||
break;
|
||||
case 'Tab':
|
||||
break;
|
||||
case 'Backspace':
|
||||
case 'Delete': {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
backspace();
|
||||
break;
|
||||
}
|
||||
|
||||
// Spinbutton incrementing/decrementing
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
enteredKeys.current = '';
|
||||
adjustSegmentValue(1);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
enteredKeys.current = '';
|
||||
adjustSegmentValue(-1);
|
||||
break;
|
||||
case 'PageUp':
|
||||
e.preventDefault();
|
||||
enteredKeys.current = '';
|
||||
adjustSegmentValue(PAGE_STEP[segment.type] || 1);
|
||||
break;
|
||||
case 'PageDown':
|
||||
e.preventDefault();
|
||||
enteredKeys.current = '';
|
||||
adjustSegmentValue(-(PAGE_STEP[segment.type] || 1));
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
enteredKeys.current = '';
|
||||
setSegmentValue(segment.maxValue);
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
enteredKeys.current = '';
|
||||
setSegmentValue(segment.minValue);
|
||||
break;
|
||||
}
|
||||
|
||||
onInput(e.key);
|
||||
};
|
||||
|
||||
const amPmFormatter = useDateFormatter({hour: 'numeric', hour12: true});
|
||||
const am = useMemo(() => {
|
||||
const amDate = new Date();
|
||||
amDate.setHours(0);
|
||||
return amPmFormatter
|
||||
.formatToParts(amDate)
|
||||
.find(part => part.type === 'dayPeriod')!.value;
|
||||
}, [amPmFormatter]);
|
||||
const pm = useMemo(() => {
|
||||
const pmDate = new Date();
|
||||
pmDate.setHours(12);
|
||||
return amPmFormatter
|
||||
.formatToParts(pmDate)
|
||||
.find(part => part.type === 'dayPeriod')!.value;
|
||||
}, [amPmFormatter]);
|
||||
|
||||
// Update date values on user keyboard input
|
||||
const onInput = (key: string) => {
|
||||
const newValue = enteredKeys.current + key;
|
||||
|
||||
switch (segment.type) {
|
||||
case 'dayPeriod':
|
||||
if (am.toLowerCase().startsWith(key)) {
|
||||
setSegmentValue(0);
|
||||
} else if (pm.toLowerCase().startsWith(key)) {
|
||||
setSegmentValue(12);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
focusManager?.focusNext();
|
||||
break;
|
||||
case 'day':
|
||||
case 'hour':
|
||||
case 'minute':
|
||||
case 'second':
|
||||
case 'month':
|
||||
case 'year': {
|
||||
if (!parser.isValidPartialNumber(newValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let numberValue = parser.parse(newValue);
|
||||
let segmentValue = numberValue;
|
||||
let allowsZero = segment.minValue === 0;
|
||||
if (segment.type === 'hour' && formatter.resolvedOptions().hour12) {
|
||||
switch (formatter.resolvedOptions().hourCycle) {
|
||||
case 'h11':
|
||||
if (numberValue > 11) {
|
||||
segmentValue = parser.parse(key);
|
||||
}
|
||||
break;
|
||||
case 'h12':
|
||||
allowsZero = false;
|
||||
if (numberValue > 12) {
|
||||
segmentValue = parser.parse(key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (segment.value >= 12 && numberValue > 1) {
|
||||
numberValue += 12;
|
||||
}
|
||||
} else if (numberValue > segment.maxValue) {
|
||||
segmentValue = parser.parse(key);
|
||||
}
|
||||
|
||||
if (Number.isNaN(numberValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSetValue = segmentValue !== 0 || allowsZero;
|
||||
if (shouldSetValue) {
|
||||
setSegmentValue(segmentValue);
|
||||
}
|
||||
|
||||
if (
|
||||
Number(`${numberValue}0`) > segment.maxValue ||
|
||||
newValue.length >= String(segment.maxValue).length
|
||||
) {
|
||||
enteredKeys.current = '';
|
||||
if (shouldSetValue) {
|
||||
focusManager?.focusNext();
|
||||
}
|
||||
} else {
|
||||
enteredKeys.current = newValue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const spinButtonProps: HTMLAttributes<HTMLDivElement> = isMobile
|
||||
? {}
|
||||
: {
|
||||
'aria-label': segment.type,
|
||||
'aria-valuetext': isPlaceholder ? undefined : `${segment.value}`,
|
||||
'aria-valuemin': segment.minValue,
|
||||
'aria-valuemax': segment.maxValue,
|
||||
'aria-valuenow': isPlaceholder ? undefined : segment.value,
|
||||
tabIndex: 0,
|
||||
onKeyDown,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
{...mergeProps(domProps!, {
|
||||
...spinButtonProps,
|
||||
onFocus: e => {
|
||||
enteredKeys.current = '';
|
||||
e.target.scrollIntoView({block: 'nearest'});
|
||||
},
|
||||
onClick: e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
} as HTMLAttributes<HTMLDivElement>)}
|
||||
className="box-content cursor-default select-none whitespace-nowrap rounded p-2 text-center tabular-nums caret-transparent outline-none focus:bg-primary focus:text-on-primary"
|
||||
>
|
||||
{segment.text.padStart(segment.minLength, '0')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {useFocusManager} from '@react-aria/focus';
|
||||
import {ComponentPropsWithoutRef} from 'react';
|
||||
|
||||
export interface LiteralSegment {
|
||||
type: 'literal';
|
||||
minLength: 1;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface LiteralSegmentProps extends ComponentPropsWithoutRef<'div'> {
|
||||
segment: LiteralSegment;
|
||||
domProps?: ComponentPropsWithoutRef<'div'>;
|
||||
}
|
||||
export function LiteralDateSegment({segment, domProps}: LiteralSegmentProps) {
|
||||
const focusManager = useFocusManager();
|
||||
return (
|
||||
<div
|
||||
{...domProps}
|
||||
onPointerDown={e => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
e.preventDefault();
|
||||
const res = focusManager?.focusNext({from: e.target as HTMLElement});
|
||||
if (!res) {
|
||||
focusManager?.focusPrevious({from: e.target as HTMLElement});
|
||||
}
|
||||
}
|
||||
}}
|
||||
aria-hidden
|
||||
className="min-w-4 cursor-default select-none"
|
||||
>
|
||||
{segment.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {ZonedDateTime} from '@internationalized/date';
|
||||
|
||||
export function adjustSegment(
|
||||
value: ZonedDateTime,
|
||||
part: string,
|
||||
amount: number,
|
||||
options: Intl.ResolvedDateTimeFormatOptions
|
||||
) {
|
||||
switch (part) {
|
||||
case 'era':
|
||||
case 'year':
|
||||
case 'month':
|
||||
case 'day':
|
||||
return value.cycle(part, amount, {round: part === 'year'});
|
||||
}
|
||||
|
||||
if ('hour' in value) {
|
||||
switch (part) {
|
||||
case 'dayPeriod': {
|
||||
const hours = value.hour;
|
||||
const isPM = hours >= 12;
|
||||
return value.set({hour: isPM ? hours - 12 : hours + 12});
|
||||
}
|
||||
case 'hour':
|
||||
case 'minute':
|
||||
case 'second':
|
||||
return value.cycle(part, amount, {
|
||||
round: part !== 'hour',
|
||||
hourCycle: options.hour12 ? 12 : 24,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
DateValue,
|
||||
getMinimumDayInMonth,
|
||||
getMinimumMonthInYear,
|
||||
} from '@internationalized/date';
|
||||
|
||||
export function getSegmentLimits(
|
||||
date: DateValue,
|
||||
type: string,
|
||||
options: Intl.ResolvedDateTimeFormatOptions
|
||||
) {
|
||||
switch (type) {
|
||||
case 'year':
|
||||
return {
|
||||
value: date.year,
|
||||
placeholder: 'yyyy',
|
||||
minValue: 1,
|
||||
maxValue: date.calendar.getYearsInEra(date),
|
||||
};
|
||||
case 'month':
|
||||
return {
|
||||
value: date.month,
|
||||
placeholder: 'mm',
|
||||
minValue: getMinimumMonthInYear(date),
|
||||
maxValue: date.calendar.getMonthsInYear(date),
|
||||
};
|
||||
case 'day':
|
||||
return {
|
||||
value: date.day,
|
||||
minValue: getMinimumDayInMonth(date),
|
||||
maxValue: date.calendar.getDaysInMonth(date),
|
||||
placeholder: 'dd',
|
||||
};
|
||||
}
|
||||
|
||||
if ('hour' in date) {
|
||||
switch (type) {
|
||||
case 'dayPeriod':
|
||||
return {
|
||||
value: date.hour >= 12 ? 12 : 0,
|
||||
minValue: 0,
|
||||
maxValue: 12,
|
||||
placeholder: '--',
|
||||
};
|
||||
case 'hour':
|
||||
if (options.hour12) {
|
||||
const isPM = date.hour >= 12;
|
||||
return {
|
||||
value: date.hour,
|
||||
minValue: isPM ? 12 : 0,
|
||||
maxValue: isPM ? 23 : 11,
|
||||
placeholder: '--',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: date.hour,
|
||||
minValue: 0,
|
||||
maxValue: 23,
|
||||
placeholder: '--',
|
||||
};
|
||||
case 'minute':
|
||||
return {
|
||||
value: date.minute,
|
||||
minValue: 0,
|
||||
maxValue: 59,
|
||||
placeholder: '--',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const PAGE_STEP = {
|
||||
year: 5,
|
||||
month: 2,
|
||||
day: 7,
|
||||
hour: 2,
|
||||
minute: 15,
|
||||
second: 15,
|
||||
dayPeriod: 1,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import {ZonedDateTime} from '@internationalized/date';
|
||||
|
||||
export function setSegment(
|
||||
value: ZonedDateTime,
|
||||
part: string,
|
||||
segmentValue: number,
|
||||
options: Intl.ResolvedDateTimeFormatOptions
|
||||
) {
|
||||
switch (part) {
|
||||
case 'day':
|
||||
case 'month':
|
||||
case 'year':
|
||||
return value.set({[part]: segmentValue});
|
||||
}
|
||||
|
||||
if ('hour' in value) {
|
||||
switch (part) {
|
||||
case 'dayPeriod': {
|
||||
const hours = value.hour;
|
||||
const wasPM = hours >= 12;
|
||||
const isPM = segmentValue >= 12;
|
||||
if (isPM === wasPM) {
|
||||
return value;
|
||||
}
|
||||
return value.set({hour: wasPM ? hours - 12 : hours + 12});
|
||||
}
|
||||
case 'hour':
|
||||
// In 12 hour time, ensure that AM/PM does not change
|
||||
if (options.hour12) {
|
||||
const hours = value.hour;
|
||||
const wasPM = hours >= 12;
|
||||
if (!wasPM && segmentValue === 12) {
|
||||
segmentValue = 0;
|
||||
}
|
||||
if (wasPM && segmentValue < 12) {
|
||||
segmentValue += 12;
|
||||
}
|
||||
}
|
||||
// fallthrough
|
||||
case 'minute':
|
||||
case 'second':
|
||||
return value.set({[part]: segmentValue});
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {useState} from 'react';
|
||||
import {DateValue, toZoned, ZonedDateTime} from '@internationalized/date';
|
||||
import {getDefaultGranularity} from './utils';
|
||||
import type {DatePickerValueProps} from './date-picker/use-date-picker-state';
|
||||
import {DateRangeValue} from './date-range-picker/date-range-value';
|
||||
import {useUserTimezone} from '@common/i18n/use-user-timezone';
|
||||
|
||||
export function useBaseDatePickerState(
|
||||
selectedDate: DateValue,
|
||||
props:
|
||||
| DatePickerValueProps<ZonedDateTime>
|
||||
| DatePickerValueProps<Partial<DateRangeValue>, DateRangeValue>
|
||||
) {
|
||||
const timezone = useUserTimezone();
|
||||
const [calendarIsOpen, setCalendarIsOpen] = useState(false);
|
||||
const closeDialogOnSelection = props.closeDialogOnSelection ?? true;
|
||||
|
||||
const granularity = props.granularity || getDefaultGranularity(selectedDate);
|
||||
const min = props.min ? toZoned(props.min, timezone) : undefined;
|
||||
const max = props.max ? toZoned(props.max, timezone) : undefined;
|
||||
|
||||
return {
|
||||
timezone,
|
||||
granularity,
|
||||
min,
|
||||
max,
|
||||
calendarIsOpen,
|
||||
setCalendarIsOpen,
|
||||
closeDialogOnSelection,
|
||||
};
|
||||
}
|
||||
19
common/resources/client/ui/forms/input-field/date/utils.ts
Executable file
19
common/resources/client/ui/forms/input-field/date/utils.ts
Executable file
@@ -0,0 +1,19 @@
|
||||
import {CalendarDate, DateValue} from '@internationalized/date';
|
||||
|
||||
export function getDefaultGranularity(date: DateValue) {
|
||||
if (date instanceof CalendarDate) {
|
||||
return 'day';
|
||||
}
|
||||
return 'minute';
|
||||
}
|
||||
|
||||
export function dateIsInvalid(
|
||||
date: CalendarDate,
|
||||
min?: DateValue,
|
||||
max?: DateValue
|
||||
) {
|
||||
return (
|
||||
(min != null && date.compare(min) < 0) ||
|
||||
(max != null && date.compare(max) > 0)
|
||||
);
|
||||
}
|
||||
137
common/resources/client/ui/forms/input-field/field.tsx
Executable file
137
common/resources/client/ui/forms/input-field/field.tsx
Executable file
@@ -0,0 +1,137 @@
|
||||
import React, {ComponentPropsWithoutRef, ReactElement, ReactNode} from 'react';
|
||||
import {Adornment} from './adornment';
|
||||
import {InputFieldStyle} from './get-input-field-class-names';
|
||||
import {BaseFieldProps} from './base-field-props';
|
||||
import {removeEmptyValuesFromObject} from '@common/utils/objects/remove-empty-values-from-object';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface FieldProps extends BaseFieldProps {
|
||||
children: ReactNode;
|
||||
wrapperProps?: ComponentPropsWithoutRef<'div'>;
|
||||
labelProps?: ComponentPropsWithoutRef<'label' | 'span'>;
|
||||
descriptionProps?: ComponentPropsWithoutRef<'div'>;
|
||||
errorMessageProps?: ComponentPropsWithoutRef<'div'>;
|
||||
fieldClassNames: InputFieldStyle;
|
||||
}
|
||||
export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
children,
|
||||
// Not every component that uses <Field> supports help text.
|
||||
description,
|
||||
errorMessage,
|
||||
descriptionProps = {},
|
||||
errorMessageProps = {},
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
adornmentPosition,
|
||||
startAppend,
|
||||
endAppend,
|
||||
fieldClassNames,
|
||||
disabled,
|
||||
wrapperProps,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={fieldClassNames.wrapper} ref={ref} {...wrapperProps}>
|
||||
<Label {...props} />
|
||||
<div className={fieldClassNames.inputWrapper}>
|
||||
<Adornment
|
||||
direction="start"
|
||||
className={fieldClassNames.adornment}
|
||||
position={adornmentPosition}
|
||||
>
|
||||
{startAdornment}
|
||||
</Adornment>
|
||||
{startAppend && (
|
||||
<Append style={fieldClassNames.append} disabled={disabled}>
|
||||
{startAppend}
|
||||
</Append>
|
||||
)}
|
||||
{children}
|
||||
{endAppend && (
|
||||
<Append style={fieldClassNames.append} disabled={disabled}>
|
||||
{endAppend}
|
||||
</Append>
|
||||
)}
|
||||
<Adornment
|
||||
direction="end"
|
||||
className={fieldClassNames.adornment}
|
||||
position={adornmentPosition}
|
||||
>
|
||||
{endAdornment}
|
||||
</Adornment>
|
||||
</div>
|
||||
{description && !errorMessage && (
|
||||
<div className={fieldClassNames.description} {...descriptionProps}>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<div className={fieldClassNames.error} {...errorMessageProps}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function Label({
|
||||
labelElementType,
|
||||
fieldClassNames,
|
||||
labelProps,
|
||||
label,
|
||||
labelSuffix,
|
||||
labelSuffixPosition = 'spaced',
|
||||
required,
|
||||
}: Omit<FieldProps, 'children'>) {
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ElementType = labelElementType || 'label';
|
||||
const labelNode = (
|
||||
<ElementType className={fieldClassNames.label} {...labelProps}>
|
||||
{label}
|
||||
{required && <span className="text-danger"> *</span>}
|
||||
</ElementType>
|
||||
);
|
||||
|
||||
if (labelSuffix) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'mb-4 flex w-full gap-4',
|
||||
labelSuffixPosition === 'spaced' ? 'items-end' : 'items-center',
|
||||
)}
|
||||
>
|
||||
{labelNode}
|
||||
<div
|
||||
className={clsx(
|
||||
'text-xs text-muted',
|
||||
labelSuffixPosition === 'spaced' ? 'ml-auto' : '',
|
||||
)}
|
||||
>
|
||||
{labelSuffix}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return labelNode;
|
||||
}
|
||||
|
||||
interface AppendProps {
|
||||
children: ReactElement;
|
||||
style: InputFieldStyle['append'];
|
||||
disabled?: boolean;
|
||||
}
|
||||
function Append({children, style, disabled}: AppendProps) {
|
||||
return React.cloneElement(children, {
|
||||
...children.props,
|
||||
disabled: children.props.disabled || disabled,
|
||||
// make sure append styles are not overwritten with empty values
|
||||
...removeEmptyValuesFromObject(style),
|
||||
});
|
||||
}
|
||||
284
common/resources/client/ui/forms/input-field/file-entry-field.tsx
Executable file
284
common/resources/client/ui/forms/input-field/file-entry-field.tsx
Executable file
@@ -0,0 +1,284 @@
|
||||
import React, {
|
||||
cloneElement,
|
||||
ComponentPropsWithRef,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useId,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {Field} from '@common/ui/forms/input-field/field';
|
||||
import {
|
||||
getInputFieldClassNames,
|
||||
InputFieldStyle,
|
||||
} from '@common/ui/forms/input-field/get-input-field-class-names';
|
||||
import {FileEntry} from '@common/uploads/file-entry';
|
||||
import {useAutoFocus} from '@common/ui/focus/use-auto-focus';
|
||||
import {UploadStrategyConfig} from '@common/uploads/uploader/strategy/upload-strategy';
|
||||
import {useActiveUpload} from '@common/uploads/uploader/use-active-upload';
|
||||
import {Disk} from '@common/uploads/types/backend-metadata';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ProgressBar} from '@common/ui/progress/progress-bar';
|
||||
import {Input} from '@common/ui/forms/input-field/input';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {useFileEntryModel} from '@common/uploads/requests/use-file-entry-model';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {validateUpload} from '@common/uploads/uploader/validate-upload';
|
||||
import {UploadedFile} from '@common/uploads/uploaded-file';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
label?: ReactNode;
|
||||
description?: ReactNode;
|
||||
invalid?: boolean;
|
||||
errorMessage?: ReactNode;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
value?: string;
|
||||
onChange?: (newValue: string) => void;
|
||||
allowedFileTypes?: string[];
|
||||
maxFileSize?: number;
|
||||
diskPrefix: string;
|
||||
disk?: Disk;
|
||||
showRemoveButton?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
export function FileEntryField({
|
||||
className,
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
diskPrefix,
|
||||
disk = Disk.uploads,
|
||||
showRemoveButton,
|
||||
invalid,
|
||||
errorMessage,
|
||||
required,
|
||||
autoFocus,
|
||||
disabled,
|
||||
allowedFileTypes,
|
||||
maxFileSize,
|
||||
}: Props) {
|
||||
const {
|
||||
uploadFile,
|
||||
entry,
|
||||
uploadStatus,
|
||||
deleteEntry,
|
||||
isDeletingEntry,
|
||||
percentage,
|
||||
} = useActiveUpload();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useAutoFocus({autoFocus}, inputRef);
|
||||
const {data} = useFileEntryModel(value, {enabled: !entry && !!value});
|
||||
|
||||
const fieldId = useId();
|
||||
const labelId = label ? `${fieldId}-label` : undefined;
|
||||
const descriptionId = description ? `${fieldId}-description` : undefined;
|
||||
|
||||
const currentValue = value || entry?.url;
|
||||
const currentEntry = entry || data?.fileEntry;
|
||||
|
||||
const uploadOptions: UploadStrategyConfig = {
|
||||
showToastOnRestrictionFail: true,
|
||||
restrictions: {
|
||||
allowedFileTypes,
|
||||
maxFileSize,
|
||||
},
|
||||
metadata: {
|
||||
diskPrefix,
|
||||
disk,
|
||||
},
|
||||
onSuccess: (entry: FileEntry) => onChange?.(entry.url),
|
||||
onError: message => {
|
||||
if (message) {
|
||||
toast.danger(message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const inputFieldClassNames = getInputFieldClassNames({
|
||||
description,
|
||||
descriptionPosition: 'top',
|
||||
invalid,
|
||||
disabled: disabled || uploadStatus === 'inProgress',
|
||||
});
|
||||
|
||||
const removeButton = showRemoveButton ? (
|
||||
<Button
|
||||
variant="link"
|
||||
color="danger"
|
||||
size="xs"
|
||||
disabled={isDeletingEntry || !currentValue || disabled}
|
||||
onClick={() => {
|
||||
deleteEntry({
|
||||
onSuccess: () => onChange?.(''),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Remove file" />
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const handleUpload = useCallback(() => {
|
||||
inputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={clsx('text-sm', className)}>
|
||||
{label && (
|
||||
<div className="flex items-center justify-between gap-24">
|
||||
<div id={labelId} className={inputFieldClassNames.label}>
|
||||
{label}
|
||||
</div>
|
||||
{removeButton}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className={inputFieldClassNames.description}>{description}</div>
|
||||
)}
|
||||
<div aria-labelledby={labelId} aria-describedby={descriptionId}>
|
||||
<Field
|
||||
fieldClassNames={inputFieldClassNames}
|
||||
errorMessage={errorMessage}
|
||||
invalid={invalid}
|
||||
>
|
||||
<FileInputField
|
||||
inputFieldClassNames={inputFieldClassNames}
|
||||
currentValue={currentValue}
|
||||
currentEntry={currentEntry}
|
||||
handleUpload={handleUpload}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={descriptionId}
|
||||
// if file is already uploaded (from form or via props) set
|
||||
// required to false, otherwise farm validation will always fail
|
||||
required={currentValue ? false : required}
|
||||
accept={allowedFileTypes?.join(',')}
|
||||
type="file"
|
||||
disabled={uploadStatus === 'inProgress'}
|
||||
className="sr-only"
|
||||
onChange={e => {
|
||||
if (e.target.files?.length) {
|
||||
// "uploadFile" will validate, but need to validate here as well
|
||||
// because there's no easy way to listen for errors using "uploadFile"
|
||||
const errorMessage = validateUpload(
|
||||
new UploadedFile(e.target.files[0]),
|
||||
uploadOptions.restrictions
|
||||
);
|
||||
if (errorMessage && inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
toast.danger(errorMessage);
|
||||
} else {
|
||||
uploadFile(e.target.files[0], uploadOptions);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FileInputField>
|
||||
{uploadStatus === 'inProgress' && (
|
||||
<ProgressBar
|
||||
className="absolute left-0 right-0 top-0"
|
||||
size="xs"
|
||||
value={percentage}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileInputFieldProps {
|
||||
children: ReactElement<ComponentPropsWithRef<'input'>>;
|
||||
inputFieldClassNames: InputFieldStyle;
|
||||
currentValue?: string;
|
||||
currentEntry?: FileEntry;
|
||||
handleUpload: () => void;
|
||||
}
|
||||
function FileInputField({
|
||||
children,
|
||||
inputFieldClassNames,
|
||||
currentValue,
|
||||
currentEntry,
|
||||
handleUpload,
|
||||
}: FileInputFieldProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
if (currentValue) {
|
||||
return (
|
||||
<Field
|
||||
wrapperProps={{
|
||||
onClick: () => {
|
||||
buttonRef.current?.focus();
|
||||
buttonRef.current?.click();
|
||||
},
|
||||
}}
|
||||
fieldClassNames={inputFieldClassNames}
|
||||
>
|
||||
<Input className={clsx(inputFieldClassNames.input, 'gap-10')}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="flex-shrink-0 rounded bg-primary px-10 py-2 text-sm font-semibold text-on-primary outline-none"
|
||||
onClick={() => handleUpload()}
|
||||
>
|
||||
<Trans message="Replace file" />
|
||||
</button>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<div className="min-w-0 overflow-hidden overflow-ellipsis whitespace-nowrap">
|
||||
{currentEntry ? (
|
||||
<m.div key="file-entry-name" {...opacityAnimation}>
|
||||
{currentEntry.name}
|
||||
</m.div>
|
||||
) : (
|
||||
<m.div key="skeleton" {...opacityAnimation}>
|
||||
<Skeleton className="min-w-144" />
|
||||
</m.div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
{children}
|
||||
</Input>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
return cloneElement(children, {
|
||||
className: clsx(
|
||||
inputFieldClassNames.input,
|
||||
'py-8',
|
||||
'file:bg-primary file:text-on-primary file:border-none file:rounded file:text-sm file:font-semibold file:px-10 file:h-24 file:mr-10'
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
interface FormFileEntryFieldProps extends Props {
|
||||
name: string;
|
||||
}
|
||||
export function FormFileEntryField(props: FormFileEntryFieldProps) {
|
||||
const {
|
||||
field: {onChange, value = null},
|
||||
fieldState: {error},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
});
|
||||
|
||||
const formProps: Partial<Props> = {
|
||||
onChange,
|
||||
value,
|
||||
invalid: error != null,
|
||||
errorMessage: error ? <Trans message="Please select a file." /> : null,
|
||||
};
|
||||
|
||||
return <FileEntryField {...mergeProps(formProps, props)} />;
|
||||
}
|
||||
66
common/resources/client/ui/forms/input-field/file-field.tsx
Executable file
66
common/resources/client/ui/forms/input-field/file-field.tsx
Executable file
@@ -0,0 +1,66 @@
|
||||
import React, {ChangeEventHandler} from 'react';
|
||||
import {mergeProps, useObjectRef} from '@react-aria/utils';
|
||||
import {useController} from 'react-hook-form';
|
||||
import clsx from 'clsx';
|
||||
import {BaseFieldProps} from './base-field-props';
|
||||
import {useField} from './use-field';
|
||||
import {getInputFieldClassNames} from './get-input-field-class-names';
|
||||
import {Field} from './field';
|
||||
import {TextFieldProps} from './text-field/text-field';
|
||||
|
||||
export interface FileFieldProps
|
||||
extends Omit<BaseFieldProps, 'type'> {
|
||||
onChange?: ChangeEventHandler<'input'>;
|
||||
accept?: string;
|
||||
}
|
||||
export const FileField = React.forwardRef<HTMLInputElement, FileFieldProps>(
|
||||
(props, ref) => {
|
||||
const inputRef = useObjectRef(ref);
|
||||
|
||||
const {fieldProps, inputProps} = useField({...props, focusRef: inputRef});
|
||||
|
||||
const inputFieldClassNames = getInputFieldClassNames(props);
|
||||
|
||||
return (
|
||||
<Field ref={ref} fieldClassNames={inputFieldClassNames} {...fieldProps}>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
{...inputProps as any}
|
||||
className={clsx(
|
||||
inputFieldClassNames.input,
|
||||
'py-8',
|
||||
'file:bg-primary file:text-on-primary file:border-none file:rounded file:text-sm file:font-semibold file:px-10 file:h-24 file:mr-10'
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export interface FormFileFieldProps extends FileFieldProps {
|
||||
name: string;
|
||||
}
|
||||
export function FormFileField({name, ...props}: FormFileFieldProps) {
|
||||
const {
|
||||
field: {onChange, onBlur, ref},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name,
|
||||
});
|
||||
|
||||
const [value, setValue] = React.useState('');
|
||||
|
||||
const formProps: TextFieldProps = {
|
||||
onChange: e => {
|
||||
onChange(e.target.files?.[0]);
|
||||
setValue(e.target.value);
|
||||
},
|
||||
onBlur,
|
||||
value,
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
};
|
||||
|
||||
return <FileField ref={ref} {...mergeProps(formProps, props)} />;
|
||||
}
|
||||
113
common/resources/client/ui/forms/input-field/file-size-field.tsx
Executable file
113
common/resources/client/ui/forms/input-field/file-size-field.tsx
Executable file
@@ -0,0 +1,113 @@
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import memoize from 'nano-memoize';
|
||||
import {
|
||||
FormTextFieldProps,
|
||||
TextField,
|
||||
TextFieldProps,
|
||||
} from './text-field/text-field';
|
||||
import {prettyBytes} from '../../../uploads/utils/pretty-bytes';
|
||||
import {Option, Select} from '../select/select';
|
||||
import {spaceUnits} from '../../../uploads/utils/space-units';
|
||||
import {
|
||||
convertToBytes,
|
||||
SpaceUnit,
|
||||
} from '../../../uploads/utils/convert-to-bytes';
|
||||
|
||||
// 99TB
|
||||
const MaxValue = 108851651149824;
|
||||
|
||||
export const FormFileSizeField = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
FormTextFieldProps
|
||||
>(({name, ...props}, ref) => {
|
||||
const {
|
||||
field: {
|
||||
onChange: setByteValue,
|
||||
onBlur,
|
||||
value: byteValue = '',
|
||||
ref: inputRef,
|
||||
},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name,
|
||||
});
|
||||
|
||||
const [liveValue, setLiveValue] = useState<number | string>('');
|
||||
const [unit, setUnit] = useState<SpaceUnit | string>('MB');
|
||||
|
||||
useEffect(() => {
|
||||
if (byteValue == null || byteValue === '') {
|
||||
setLiveValue('');
|
||||
return;
|
||||
}
|
||||
const {amount, unit: newUnit} = fromBytes({
|
||||
bytes: Math.min(byteValue, MaxValue),
|
||||
});
|
||||
setUnit(newUnit || 'MB');
|
||||
setLiveValue(Number.isNaN(amount) ? '' : amount);
|
||||
}, [byteValue, unit]);
|
||||
|
||||
const formProps: TextFieldProps = {
|
||||
onChange: e => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (Number.isNaN(value)) {
|
||||
setByteValue(value);
|
||||
} else {
|
||||
const newBytes = convertToBytes(
|
||||
parseInt(e.target.value),
|
||||
unit as SpaceUnit
|
||||
);
|
||||
setByteValue(newBytes);
|
||||
}
|
||||
},
|
||||
onBlur,
|
||||
value: liveValue,
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
inputRef,
|
||||
};
|
||||
|
||||
const unitSelect = (
|
||||
<Select
|
||||
minWidth="min-w-80"
|
||||
selectionMode="single"
|
||||
selectedValue={unit}
|
||||
disabled={!byteValue}
|
||||
onSelectionChange={newUnit => {
|
||||
const newBytes = convertToBytes(
|
||||
(liveValue || 0) as number,
|
||||
newUnit as SpaceUnit
|
||||
);
|
||||
setByteValue(newBytes);
|
||||
}}
|
||||
>
|
||||
{spaceUnits.slice(0, 5).map(u => (
|
||||
<Option value={u} key={u}>
|
||||
{u === 'B' ? 'Bytes' : u}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
{...mergeProps(formProps, props)}
|
||||
type="number"
|
||||
ref={ref}
|
||||
endAppend={unitSelect}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const fromBytes = memoize(
|
||||
({bytes}: {bytes: number}): {amount: number | string; unit: SpaceUnit} => {
|
||||
const pretty = prettyBytes(bytes);
|
||||
if (!pretty) return {amount: '', unit: 'MB'};
|
||||
let amount = parseInt(pretty.split(' ')[0]);
|
||||
// get rid of any punctuation
|
||||
amount = Math.round(amount);
|
||||
return {amount, unit: pretty.split(' ')[1] as SpaceUnit};
|
||||
}
|
||||
);
|
||||
233
common/resources/client/ui/forms/input-field/get-input-field-class-names.ts
Executable file
233
common/resources/client/ui/forms/input-field/get-input-field-class-names.ts
Executable file
@@ -0,0 +1,233 @@
|
||||
import clsx from 'clsx';
|
||||
import {BaseFieldProps} from './base-field-props';
|
||||
import {ButtonSize, getButtonSizeStyle} from '../../buttons/button-size';
|
||||
|
||||
export interface InputFieldStyle {
|
||||
label: string;
|
||||
input: string;
|
||||
wrapper: string;
|
||||
inputWrapper: string;
|
||||
adornment: string;
|
||||
append: {size: string; radius: string};
|
||||
size: {font: string; height: string};
|
||||
description: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
type InputFieldStyleProps = Omit<
|
||||
BaseFieldProps,
|
||||
'value' | 'defaultValue' | 'onChange'
|
||||
>;
|
||||
|
||||
export function getInputFieldClassNames(
|
||||
props: InputFieldStyleProps = {},
|
||||
): InputFieldStyle {
|
||||
const {
|
||||
size = 'md',
|
||||
startAppend,
|
||||
endAppend,
|
||||
className,
|
||||
labelPosition,
|
||||
labelDisplay = 'block',
|
||||
inputClassName,
|
||||
inputWrapperClassName,
|
||||
unstyled,
|
||||
invalid,
|
||||
disabled,
|
||||
background = 'bg-transparent',
|
||||
flexibleHeight,
|
||||
inputShadow = 'shadow-sm',
|
||||
descriptionPosition = 'bottom',
|
||||
inputRing,
|
||||
inputFontSize,
|
||||
labelSuffix,
|
||||
} = {...props};
|
||||
|
||||
if (unstyled) {
|
||||
return {
|
||||
label: '',
|
||||
input: inputClassName || '',
|
||||
wrapper: className || '',
|
||||
inputWrapper: inputWrapperClassName || '',
|
||||
adornment: '',
|
||||
append: {size: '', radius: ''},
|
||||
size: {font: '', height: ''},
|
||||
description: '',
|
||||
error: '',
|
||||
};
|
||||
}
|
||||
|
||||
const sizeClass = inputSizeClass({
|
||||
size: props.size,
|
||||
flexibleHeight,
|
||||
});
|
||||
if (inputFontSize) {
|
||||
sizeClass.font = inputFontSize;
|
||||
}
|
||||
|
||||
const isInputGroup = startAppend || endAppend;
|
||||
|
||||
const ringColor = invalid
|
||||
? 'focus:ring-danger/focus focus:border-danger/60'
|
||||
: 'focus:ring-primary/focus focus:border-primary/60';
|
||||
const ringClassName = inputRing || `focus:ring ${ringColor}`;
|
||||
|
||||
const radius = getRadius(props);
|
||||
|
||||
return {
|
||||
label: clsx(
|
||||
labelDisplay,
|
||||
'first-letter:capitalize text-left whitespace-nowrap',
|
||||
disabled && 'text-disabled',
|
||||
sizeClass.font,
|
||||
labelSuffix ? '' : labelPosition === 'side' ? 'mr-16' : 'mb-4',
|
||||
),
|
||||
input: clsx(
|
||||
'block text-left relative w-full appearance-none transition-shadow text',
|
||||
background,
|
||||
|
||||
// radius
|
||||
radius.input,
|
||||
|
||||
getInputBorder(props),
|
||||
!disabled && `${ringClassName} focus:outline-none ${inputShadow}`,
|
||||
disabled && 'text-disabled cursor-not-allowed',
|
||||
inputClassName,
|
||||
sizeClass.font,
|
||||
sizeClass.height,
|
||||
getInputPadding(props),
|
||||
),
|
||||
adornment: iconSizeClass(size),
|
||||
append: {
|
||||
size: getButtonSizeStyle(size),
|
||||
radius: radius.append,
|
||||
},
|
||||
wrapper: clsx(className, sizeClass.font, {
|
||||
'flex items-center': labelPosition === 'side',
|
||||
}),
|
||||
inputWrapper: clsx(
|
||||
'isolate relative',
|
||||
inputWrapperClassName,
|
||||
isInputGroup && 'flex items-stretch',
|
||||
),
|
||||
size: sizeClass,
|
||||
description: `text-muted ${
|
||||
descriptionPosition === 'bottom' ? 'pt-10' : 'pb-10'
|
||||
} text-xs`,
|
||||
error: 'text-danger pt-10 text-xs',
|
||||
};
|
||||
}
|
||||
|
||||
function getInputBorder({
|
||||
startAppend,
|
||||
endAppend,
|
||||
inputBorder,
|
||||
invalid,
|
||||
}: InputFieldStyleProps) {
|
||||
if (inputBorder) return inputBorder;
|
||||
|
||||
const isInputGroup = startAppend || endAppend;
|
||||
const borderColor = invalid ? 'border-danger' : 'border-divider';
|
||||
|
||||
if (!isInputGroup) {
|
||||
return `${borderColor} border`;
|
||||
}
|
||||
if (startAppend) {
|
||||
return `${borderColor} border-y border-r`;
|
||||
}
|
||||
return `${borderColor} border-y border-l`;
|
||||
}
|
||||
|
||||
function getInputPadding({
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
inputRadius,
|
||||
}: InputFieldStyleProps) {
|
||||
if (inputRadius === 'rounded-full') {
|
||||
return clsx(
|
||||
startAdornment ? 'pl-54' : 'pl-28',
|
||||
endAdornment ? 'pr-54' : 'pr-28',
|
||||
);
|
||||
}
|
||||
return clsx(
|
||||
startAdornment ? 'pl-46' : 'pl-12',
|
||||
endAdornment ? 'pr-46' : 'pr-12',
|
||||
);
|
||||
}
|
||||
|
||||
function getRadius(props: InputFieldStyleProps): {
|
||||
input: string;
|
||||
append: string;
|
||||
} {
|
||||
const {startAppend, endAppend, inputRadius} = props;
|
||||
const isInputGroup = startAppend || endAppend;
|
||||
|
||||
if (inputRadius === 'rounded-full') {
|
||||
return {
|
||||
input: clsx(
|
||||
!isInputGroup && 'rounded-full',
|
||||
startAppend && 'rounded-r-full rounded-l-none',
|
||||
endAppend && 'rounded-l-full rounded-r-none',
|
||||
),
|
||||
append: startAppend ? 'rounded-l-full' : 'rounded-r-full',
|
||||
};
|
||||
} else if (inputRadius === 'rounded-none') {
|
||||
return {
|
||||
input: '',
|
||||
append: '',
|
||||
};
|
||||
} else if (inputRadius) {
|
||||
return {
|
||||
input: inputRadius,
|
||||
append: inputRadius,
|
||||
};
|
||||
}
|
||||
return {
|
||||
input: clsx(
|
||||
!isInputGroup && 'rounded-input',
|
||||
startAppend && 'rounded-r-input rounded-l-none',
|
||||
endAppend && 'rounded-l-input rounded-r-none',
|
||||
),
|
||||
append: startAppend ? 'rounded-l-input' : 'rounded-r-input',
|
||||
};
|
||||
}
|
||||
|
||||
function inputSizeClass({size, flexibleHeight}: BaseFieldProps) {
|
||||
switch (size) {
|
||||
case '2xs':
|
||||
return {font: 'text-xs', height: flexibleHeight ? 'min-h-24' : 'h-24'};
|
||||
case 'xs':
|
||||
return {font: 'text-xs', height: flexibleHeight ? 'min-h-30' : 'h-30'};
|
||||
case 'sm':
|
||||
return {font: 'text-sm', height: flexibleHeight ? 'min-h-36' : 'h-36'};
|
||||
case 'lg':
|
||||
return {
|
||||
font: 'text-md md:text-lg',
|
||||
height: flexibleHeight ? 'min-h-50' : 'h-50',
|
||||
};
|
||||
case 'xl':
|
||||
return {font: 'text-xl', height: flexibleHeight ? 'min-h-60' : 'h-60'};
|
||||
default:
|
||||
return {font: 'text-sm', height: flexibleHeight ? 'min-h-42' : 'h-42'};
|
||||
}
|
||||
}
|
||||
|
||||
function iconSizeClass(size?: ButtonSize): string {
|
||||
switch (size) {
|
||||
case '2xs':
|
||||
return 'icon-2xs';
|
||||
case 'xs':
|
||||
return 'icon-xs';
|
||||
case 'sm':
|
||||
return 'icon-sm';
|
||||
case 'md':
|
||||
return 'icon-sm';
|
||||
case 'lg':
|
||||
return 'icon-lg';
|
||||
case 'xl':
|
||||
return 'icon-xl';
|
||||
default:
|
||||
// can't return "size" variable here, append in field will not work with it
|
||||
return '';
|
||||
}
|
||||
}
|
||||
1
common/resources/client/ui/forms/input-field/input-size.tsx
Executable file
1
common/resources/client/ui/forms/input-field/input-size.tsx
Executable file
@@ -0,0 +1 @@
|
||||
export type InputSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
45
common/resources/client/ui/forms/input-field/input.tsx
Executable file
45
common/resources/client/ui/forms/input-field/input.tsx
Executable file
@@ -0,0 +1,45 @@
|
||||
import {FocusScope} from '@react-aria/focus';
|
||||
import React, {ComponentPropsWithoutRef, CSSProperties, ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface InputProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
style?: CSSProperties;
|
||||
inputProps?: ComponentPropsWithoutRef<'div'>;
|
||||
wrapperProps?: ComponentPropsWithoutRef<'div'>;
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLDivElement, InputProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
children,
|
||||
inputProps,
|
||||
wrapperProps,
|
||||
className,
|
||||
autoFocus,
|
||||
style,
|
||||
onClick,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div {...wrapperProps} onClick={onClick}>
|
||||
<div
|
||||
{...inputProps}
|
||||
role="group"
|
||||
className={clsx(
|
||||
className,
|
||||
'flex items-center focus-within:ring focus-within:ring-primary/focus focus-within:border-primary/60'
|
||||
)}
|
||||
ref={ref}
|
||||
style={style}
|
||||
>
|
||||
<FocusScope autoFocus={autoFocus}>{children}</FocusScope>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
71
common/resources/client/ui/forms/input-field/text-field/text-field-base.tsx
Executable file
71
common/resources/client/ui/forms/input-field/text-field/text-field-base.tsx
Executable file
@@ -0,0 +1,71 @@
|
||||
import React, {ComponentPropsWithoutRef, forwardRef, Ref} from 'react';
|
||||
import type {TextFieldProps} from './text-field';
|
||||
import {Field} from '../field';
|
||||
import {getInputFieldClassNames} from '../get-input-field-class-names';
|
||||
|
||||
interface Props extends TextFieldProps {
|
||||
labelProps?: ComponentPropsWithoutRef<'label'>;
|
||||
inputProps:
|
||||
| ComponentPropsWithoutRef<'input'>
|
||||
| ComponentPropsWithoutRef<'textarea'>;
|
||||
descriptionProps?: ComponentPropsWithoutRef<'div'>;
|
||||
errorMessageProps?: ComponentPropsWithoutRef<'div'>;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
isLoading?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export const TextFieldBase = forwardRef<HTMLDivElement, Props>((props, ref) => {
|
||||
const {
|
||||
label,
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
startAppend,
|
||||
endAppend,
|
||||
errorMessage,
|
||||
description,
|
||||
labelProps,
|
||||
inputProps,
|
||||
inputRef,
|
||||
descriptionProps,
|
||||
errorMessageProps,
|
||||
inputWrapperClassName,
|
||||
className,
|
||||
inputClassName,
|
||||
disabled,
|
||||
inputElementType,
|
||||
rows,
|
||||
} = props;
|
||||
|
||||
const isTextArea = inputElementType === 'textarea';
|
||||
const ElementType: React.ElementType = isTextArea ? 'textarea' : 'input';
|
||||
const fieldClassNames = getInputFieldClassNames(props);
|
||||
|
||||
return (
|
||||
<Field
|
||||
ref={ref}
|
||||
label={label}
|
||||
labelProps={labelProps}
|
||||
startAdornment={startAdornment}
|
||||
endAdornment={endAdornment}
|
||||
startAppend={startAppend}
|
||||
endAppend={endAppend}
|
||||
errorMessage={errorMessage}
|
||||
description={description}
|
||||
descriptionProps={descriptionProps}
|
||||
errorMessageProps={errorMessageProps}
|
||||
inputWrapperClassName={inputWrapperClassName}
|
||||
className={className}
|
||||
inputClassName={inputClassName}
|
||||
fieldClassNames={fieldClassNames}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ElementType
|
||||
ref={inputRef as any}
|
||||
{...(inputProps as any)}
|
||||
rows={isTextArea ? rows || 4 : undefined}
|
||||
className={fieldClassNames.input}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
});
|
||||
89
common/resources/client/ui/forms/input-field/text-field/text-field.tsx
Executable file
89
common/resources/client/ui/forms/input-field/text-field/text-field.tsx
Executable file
@@ -0,0 +1,89 @@
|
||||
import React, {forwardRef, HTMLProps, Ref} from 'react';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps, useObjectRef} from '@react-aria/utils';
|
||||
import {BaseFieldPropsWithDom} from '../base-field-props';
|
||||
import {getInputFieldClassNames} from '../get-input-field-class-names';
|
||||
import {Field} from '../field';
|
||||
import {useField} from '../use-field';
|
||||
|
||||
export interface TextFieldProps
|
||||
extends BaseFieldPropsWithDom<HTMLInputElement> {
|
||||
rows?: number;
|
||||
inputElementType?: 'input' | 'textarea';
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
value?: string | number;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
|
||||
(
|
||||
{
|
||||
inputElementType = 'input',
|
||||
flexibleHeight,
|
||||
inputRef,
|
||||
inputTestId,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const inputObjRef = useObjectRef(inputRef);
|
||||
|
||||
const {fieldProps, inputProps} = useField<HTMLInputElement>({
|
||||
...props,
|
||||
focusRef: inputObjRef,
|
||||
});
|
||||
|
||||
const isTextArea = inputElementType === 'textarea';
|
||||
const ElementType: React.ElementType = isTextArea ? 'textarea' : 'input';
|
||||
const inputFieldClassNames = getInputFieldClassNames({
|
||||
...props,
|
||||
flexibleHeight: flexibleHeight || inputElementType === 'textarea',
|
||||
});
|
||||
|
||||
if (inputElementType === 'textarea' && !props.unstyled) {
|
||||
inputFieldClassNames.input = `${inputFieldClassNames.input} py-12`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Field ref={ref} fieldClassNames={inputFieldClassNames} {...fieldProps}>
|
||||
<ElementType
|
||||
data-testid={inputTestId}
|
||||
ref={inputObjRef}
|
||||
{...(inputProps as any)}
|
||||
rows={
|
||||
isTextArea
|
||||
? (inputProps as HTMLProps<HTMLTextAreaElement>).rows || 4
|
||||
: undefined
|
||||
}
|
||||
className={inputFieldClassNames.input}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export interface FormTextFieldProps extends TextFieldProps {
|
||||
name: string;
|
||||
}
|
||||
export const FormTextField = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
FormTextFieldProps
|
||||
>(({name, ...props}, ref) => {
|
||||
const {
|
||||
field: {onChange, onBlur, value = '', ref: inputRef},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name,
|
||||
});
|
||||
|
||||
const formProps: TextFieldProps = {
|
||||
onChange,
|
||||
onBlur,
|
||||
value: value == null ? '' : value, // avoid issues with "null" value when setting form defaults from backend model
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
inputRef,
|
||||
name,
|
||||
};
|
||||
|
||||
return <TextField ref={ref} {...mergeProps(formProps, props)} />;
|
||||
});
|
||||
138
common/resources/client/ui/forms/input-field/use-field.ts
Executable file
138
common/resources/client/ui/forms/input-field/use-field.ts
Executable file
@@ -0,0 +1,138 @@
|
||||
import {HTMLAttributes, HTMLProps, RefObject, useId} from 'react';
|
||||
import {BaseFieldPropsWithDom} from './base-field-props';
|
||||
import {useAutoFocus} from '../../focus/use-auto-focus';
|
||||
import type {FieldProps} from './field';
|
||||
|
||||
interface UseFieldReturn<T> {
|
||||
fieldProps: Omit<FieldProps, 'fieldClassNames' | 'children'>;
|
||||
inputProps: HTMLAttributes<T>;
|
||||
}
|
||||
|
||||
interface Props<T> extends BaseFieldPropsWithDom<T> {
|
||||
focusRef: RefObject<HTMLElement>;
|
||||
}
|
||||
export function useField<T>(props: Props<T>): UseFieldReturn<T> {
|
||||
const {
|
||||
focusRef,
|
||||
labelElementType = 'label',
|
||||
label,
|
||||
labelSuffix,
|
||||
labelSuffixPosition,
|
||||
autoFocus,
|
||||
autoSelectText,
|
||||
labelPosition,
|
||||
descriptionPosition,
|
||||
size,
|
||||
errorMessage,
|
||||
description,
|
||||
flexibleHeight,
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
startAppend,
|
||||
adornmentPosition,
|
||||
endAppend,
|
||||
className,
|
||||
inputClassName,
|
||||
inputWrapperClassName,
|
||||
unstyled,
|
||||
background,
|
||||
invalid,
|
||||
disabled,
|
||||
id,
|
||||
inputRadius,
|
||||
inputBorder,
|
||||
inputShadow,
|
||||
inputRing,
|
||||
inputFontSize,
|
||||
...inputDomProps
|
||||
} = props;
|
||||
|
||||
useAutoFocus(props, focusRef);
|
||||
|
||||
const defaultId = useId();
|
||||
const inputId = id || defaultId;
|
||||
const labelId = `${inputId}-label`;
|
||||
const descriptionId = `${inputId}-description`;
|
||||
const errorId = `${inputId}-error`;
|
||||
|
||||
const labelProps = {
|
||||
id: labelId,
|
||||
htmlFor: labelElementType === 'label' ? inputId : undefined,
|
||||
};
|
||||
const descriptionProps = {
|
||||
id: descriptionId,
|
||||
};
|
||||
const errorMessageProps = {
|
||||
id: errorId,
|
||||
};
|
||||
|
||||
const ariaLabel =
|
||||
!props.label && !props['aria-label'] && props.placeholder
|
||||
? props.placeholder
|
||||
: props['aria-label'];
|
||||
|
||||
const inputProps: HTMLProps<T> = {
|
||||
'aria-label': ariaLabel,
|
||||
'aria-invalid': invalid || undefined,
|
||||
id: inputId,
|
||||
disabled,
|
||||
...inputDomProps,
|
||||
};
|
||||
|
||||
const labelledBy = [];
|
||||
if (label) {
|
||||
labelledBy.push(labelProps.id);
|
||||
}
|
||||
if (inputProps['aria-labelledby']) {
|
||||
labelledBy.push(inputProps['aria-labelledby']);
|
||||
}
|
||||
inputProps['aria-labelledby'] = labelledBy.length
|
||||
? labelledBy.join(' ')
|
||||
: undefined;
|
||||
|
||||
const describedBy = [];
|
||||
if (description) {
|
||||
describedBy.push(descriptionProps.id);
|
||||
}
|
||||
if (errorMessage) {
|
||||
describedBy.push(errorMessageProps.id);
|
||||
}
|
||||
if (inputProps['aria-describedby']) {
|
||||
describedBy.push(inputProps['aria-describedby']);
|
||||
}
|
||||
inputProps['aria-describedby'] = describedBy.length
|
||||
? describedBy.join(' ')
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
fieldProps: {
|
||||
errorMessageProps,
|
||||
descriptionProps,
|
||||
labelProps,
|
||||
disabled,
|
||||
label,
|
||||
labelSuffix,
|
||||
labelSuffixPosition,
|
||||
autoFocus,
|
||||
autoSelectText,
|
||||
labelPosition,
|
||||
descriptionPosition,
|
||||
size,
|
||||
errorMessage,
|
||||
description,
|
||||
flexibleHeight,
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
startAppend,
|
||||
adornmentPosition,
|
||||
endAppend,
|
||||
className,
|
||||
inputClassName,
|
||||
inputWrapperClassName,
|
||||
unstyled,
|
||||
background,
|
||||
invalid,
|
||||
},
|
||||
inputProps,
|
||||
};
|
||||
}
|
||||
131
common/resources/client/ui/forms/listbox/build-listbox-collection.ts
Executable file
131
common/resources/client/ui/forms/listbox/build-listbox-collection.ts
Executable file
@@ -0,0 +1,131 @@
|
||||
import {Children, isValidElement, ReactElement, ReactNode} from 'react';
|
||||
import memoize from 'nano-memoize';
|
||||
import {ListboxItemProps} from './item';
|
||||
import {ListboxSectionProps, Section} from './section';
|
||||
import {ListBoxChildren} from './types';
|
||||
import {MessageDescriptor} from '@common/i18n/message-descriptor';
|
||||
|
||||
export type ListboxCollection = Map<string | number, CollectionItem<any>>;
|
||||
|
||||
export type CollectionItem<T> = {
|
||||
index: number;
|
||||
textLabel: string;
|
||||
element: ReactElement<ListboxItemProps>;
|
||||
value: string | number;
|
||||
item?: T;
|
||||
isDisabled?: boolean;
|
||||
section?: ReactElement<ListboxSectionProps>;
|
||||
};
|
||||
|
||||
type Props<T> = ListBoxChildren<T> & {
|
||||
inputValue?: string;
|
||||
maxItems?: number;
|
||||
};
|
||||
|
||||
export const buildListboxCollection = memoize(
|
||||
({maxItems, children, items, inputValue}: Props<any>) => {
|
||||
let collection = childrenToCollection({children, items});
|
||||
let filteredCollection = filterCollection({collection, inputValue});
|
||||
|
||||
if (maxItems) {
|
||||
collection = new Map([...collection.entries()].slice(0, maxItems));
|
||||
filteredCollection = new Map(
|
||||
[...filteredCollection.entries()].slice(0, maxItems)
|
||||
);
|
||||
}
|
||||
|
||||
return {collection, filteredCollection};
|
||||
}
|
||||
);
|
||||
|
||||
type filterCollectionProps = {
|
||||
collection: ListboxCollection;
|
||||
inputValue?: string;
|
||||
};
|
||||
const filterCollection = memoize(
|
||||
({collection, inputValue}: filterCollectionProps) => {
|
||||
let filteredCollection: ListboxCollection = new Map();
|
||||
|
||||
const query = inputValue ? `${inputValue}`.toLowerCase().trim() : '';
|
||||
if (!query) {
|
||||
filteredCollection = collection;
|
||||
} else {
|
||||
let filterIndex = 0;
|
||||
collection.forEach((meta, value) => {
|
||||
const haystack = meta.item ? JSON.stringify(meta.item) : meta.textLabel;
|
||||
if (haystack.toLowerCase().trim().includes(query)) {
|
||||
filteredCollection.set(value, {...meta, index: filterIndex++});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filteredCollection;
|
||||
}
|
||||
);
|
||||
|
||||
const childrenToCollection = memoize(
|
||||
({children, items}: ListBoxChildren<any>) => {
|
||||
let reactChildren: ReactNode;
|
||||
if (items && typeof children === 'function') {
|
||||
reactChildren = items.map(item => children(item));
|
||||
} else {
|
||||
reactChildren = children as ReactNode;
|
||||
}
|
||||
|
||||
const collection = new Map<string | number, CollectionItem<any>>();
|
||||
let optionIndex = 0;
|
||||
|
||||
const setOption = (
|
||||
element: ReactElement<ListboxItemProps>,
|
||||
section?: any,
|
||||
sectionIndex?: number,
|
||||
sectionItemIndex?: number
|
||||
) => {
|
||||
const index = optionIndex++;
|
||||
const item = section
|
||||
? // get item from nested array
|
||||
items?.[sectionIndex!].items[sectionItemIndex!]
|
||||
: // get item from flat array
|
||||
items?.[index];
|
||||
|
||||
collection.set(element.props.value, {
|
||||
index,
|
||||
element,
|
||||
textLabel: getTextLabel(element),
|
||||
item,
|
||||
section,
|
||||
isDisabled: element.props.isDisabled,
|
||||
value: element.props.value,
|
||||
});
|
||||
};
|
||||
|
||||
Children.forEach(reactChildren, (child, childIndex) => {
|
||||
if (!isValidElement(child)) return;
|
||||
if (child.type === Section) {
|
||||
Children.forEach(
|
||||
child.props.children,
|
||||
(nestedChild, nestedChildIndex) => {
|
||||
setOption(nestedChild, child, childIndex, nestedChildIndex);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setOption(child as ReactElement<ListboxItemProps>);
|
||||
}
|
||||
});
|
||||
|
||||
return collection;
|
||||
}
|
||||
);
|
||||
|
||||
function getTextLabel(item: ReactElement<ListboxItemProps>): string {
|
||||
const content = item.props.children as any;
|
||||
|
||||
if (item.props.textLabel) {
|
||||
return item.props.textLabel;
|
||||
}
|
||||
if ((content?.props as MessageDescriptor)?.message) {
|
||||
return content.props.message;
|
||||
}
|
||||
|
||||
return `${content}` || '';
|
||||
}
|
||||
99
common/resources/client/ui/forms/listbox/item.tsx
Executable file
99
common/resources/client/ui/forms/listbox/item.tsx
Executable file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import {useListboxContext} from './listbox-context';
|
||||
import {ListItemBase, ListItemBaseProps} from '../../list/list-item-base';
|
||||
|
||||
export interface ListboxItemProps extends ListItemBaseProps {
|
||||
value: any;
|
||||
textLabel?: string;
|
||||
onSelected?: () => void;
|
||||
onKeyDown?: any;
|
||||
tabIndex?: number;
|
||||
className?: string;
|
||||
capitalizeFirst?: boolean;
|
||||
}
|
||||
export function Item({
|
||||
children,
|
||||
value,
|
||||
startIcon,
|
||||
endIcon,
|
||||
endSection,
|
||||
description,
|
||||
capitalizeFirst,
|
||||
textLabel,
|
||||
isDisabled,
|
||||
onSelected,
|
||||
onClick,
|
||||
...domProps
|
||||
}: ListboxItemProps) {
|
||||
const {
|
||||
collection,
|
||||
showCheckmark,
|
||||
virtualFocus,
|
||||
listboxId,
|
||||
role,
|
||||
listItemsRef,
|
||||
handleItemSelection,
|
||||
state: {selectedValues, activeIndex, setActiveIndex},
|
||||
} = useListboxContext();
|
||||
const isSelected = selectedValues.includes(value);
|
||||
const index = collection.get(value)?.index;
|
||||
const isActive = activeIndex === index;
|
||||
|
||||
// context value might get out of sync with item due to AnimatePresence
|
||||
if (index == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabIndex = isActive && !isDisabled ? -1 : 0;
|
||||
|
||||
return (
|
||||
<ListItemBase
|
||||
{...domProps}
|
||||
onFocus={() => {
|
||||
if (!virtualFocus) {
|
||||
setActiveIndex(index);
|
||||
}
|
||||
}}
|
||||
onPointerEnter={e => {
|
||||
setActiveIndex(index);
|
||||
if (!virtualFocus) {
|
||||
e.currentTarget.focus();
|
||||
}
|
||||
}}
|
||||
onPointerDown={e => {
|
||||
if (virtualFocus) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleItemSelection(value);
|
||||
onSelected?.();
|
||||
}
|
||||
}}
|
||||
onClick={e => {
|
||||
handleItemSelection(value);
|
||||
onSelected?.();
|
||||
onClick?.(e);
|
||||
}}
|
||||
ref={node => (listItemsRef.current[index] = node)}
|
||||
id={`${listboxId}-${index}`}
|
||||
role={role === 'menu' ? 'menuitem' : 'option'}
|
||||
tabIndex={virtualFocus ? undefined : tabIndex}
|
||||
aria-selected={isActive && isSelected}
|
||||
showCheckmark={showCheckmark}
|
||||
isDisabled={isDisabled}
|
||||
isActive={isActive}
|
||||
isSelected={isSelected}
|
||||
startIcon={startIcon}
|
||||
description={description}
|
||||
endIcon={endIcon}
|
||||
endSection={endSection}
|
||||
capitalizeFirst={capitalizeFirst}
|
||||
data-value={value}
|
||||
>
|
||||
{children}
|
||||
</ListItemBase>
|
||||
);
|
||||
}
|
||||
11
common/resources/client/ui/forms/listbox/listbox-context.ts
Executable file
11
common/resources/client/ui/forms/listbox/listbox-context.ts
Executable file
@@ -0,0 +1,11 @@
|
||||
import {createContext, useContext} from 'react';
|
||||
import {UseListboxReturn} from './types';
|
||||
|
||||
type ListBoxReturnType = UseListboxReturn;
|
||||
export type ListboxContextValue = ListBoxReturnType;
|
||||
|
||||
export const ListBoxContext = createContext<ListboxContextValue>(null!);
|
||||
|
||||
export function useListboxContext() {
|
||||
return useContext(ListBoxContext);
|
||||
}
|
||||
202
common/resources/client/ui/forms/listbox/listbox.tsx
Executable file
202
common/resources/client/ui/forms/listbox/listbox.tsx
Executable file
@@ -0,0 +1,202 @@
|
||||
import {AnimatePresence} from 'framer-motion';
|
||||
import React, {
|
||||
cloneElement,
|
||||
ComponentPropsWithoutRef,
|
||||
JSXElementConstructor,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {ListBoxContext, useListboxContext} from './listbox-context';
|
||||
import {useIsMobileDevice} from '@common/utils/hooks/is-mobile-device';
|
||||
import {Popover} from '../../overlays/popover';
|
||||
import {Tray} from '../../overlays/tray';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {UseListboxReturn} from './types';
|
||||
import {OverlayProps} from '../../overlays/overlay-props';
|
||||
import {rootEl} from '@common/core/root-el';
|
||||
|
||||
interface Props extends ComponentPropsWithoutRef<'div'> {
|
||||
listbox: UseListboxReturn;
|
||||
mobileOverlay?: JSXElementConstructor<OverlayProps>;
|
||||
children?: ReactElement;
|
||||
searchField?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
onClose?: () => void;
|
||||
prepend?: boolean;
|
||||
}
|
||||
export function Listbox({
|
||||
listbox,
|
||||
children: trigger,
|
||||
isLoading,
|
||||
mobileOverlay = Tray,
|
||||
searchField,
|
||||
onClose,
|
||||
prepend,
|
||||
className: listboxClassName,
|
||||
...domProps
|
||||
}: Props) {
|
||||
const isMobile = useIsMobileDevice();
|
||||
const {
|
||||
floatingWidth,
|
||||
floatingMinWidth = 'min-w-180',
|
||||
collection,
|
||||
showEmptyMessage,
|
||||
state: {isOpen, setIsOpen},
|
||||
positionStyle,
|
||||
floating,
|
||||
refs,
|
||||
} = listbox;
|
||||
|
||||
const Overlay = !prepend && isMobile ? mobileOverlay : Popover;
|
||||
|
||||
const className = clsx(
|
||||
'text-base sm:text-sm outline-none bg max-h-inherit flex flex-col',
|
||||
!prepend && 'shadow-xl border py-4',
|
||||
listboxClassName,
|
||||
|
||||
// tray will apply its own rounding and max width
|
||||
Overlay === Popover && 'rounded-panel',
|
||||
Overlay === Popover && floatingWidth === 'auto'
|
||||
? `max-w-288 ${floatingMinWidth}`
|
||||
: '',
|
||||
);
|
||||
|
||||
const children = useMemo(() => {
|
||||
let sectionIndex = 0;
|
||||
const renderedSections: ReactElement[] = [];
|
||||
return [...collection.values()].reduce<ReactElement[]>((prev, curr) => {
|
||||
if (!curr.section) {
|
||||
prev.push(
|
||||
cloneElement(curr.element, {
|
||||
key: curr.element.key || curr.element.props.value,
|
||||
}),
|
||||
);
|
||||
} else if (!renderedSections.includes(curr.section)) {
|
||||
const section = cloneElement(curr.section, {
|
||||
key: curr.section.key || sectionIndex,
|
||||
index: sectionIndex,
|
||||
});
|
||||
prev.push(section);
|
||||
// clone element will create new instance of object, need to keep
|
||||
// track of original instance so sections are not duplicated
|
||||
renderedSections.push(curr.section);
|
||||
sectionIndex++;
|
||||
}
|
||||
return prev;
|
||||
}, []);
|
||||
}, [collection]);
|
||||
|
||||
const showContent = children.length > 0 || (showEmptyMessage && !isLoading);
|
||||
|
||||
const innerContent = showContent ? (
|
||||
<div className={className} role="presentation">
|
||||
{searchField}
|
||||
<FocusContainer isLoading={isLoading} {...domProps}>
|
||||
{children}
|
||||
</FocusContainer>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<ListBoxContext.Provider value={listbox}>
|
||||
{trigger}
|
||||
{prepend
|
||||
? innerContent
|
||||
: rootEl &&
|
||||
createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && showContent && (
|
||||
<Overlay
|
||||
triggerRef={refs.reference as RefObject<HTMLElement>}
|
||||
restoreFocus
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose?.();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
isDismissable
|
||||
style={positionStyle}
|
||||
ref={floating}
|
||||
>
|
||||
{innerContent!}
|
||||
</Overlay>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
rootEl,
|
||||
)}
|
||||
</ListBoxContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface WrapperProps extends ComponentPropsWithoutRef<'div'> {
|
||||
isLoading?: boolean;
|
||||
children: ReactElement[];
|
||||
}
|
||||
function FocusContainer({
|
||||
className,
|
||||
children,
|
||||
isLoading,
|
||||
...domProps
|
||||
}: WrapperProps) {
|
||||
const {
|
||||
role,
|
||||
listboxId,
|
||||
virtualFocus,
|
||||
focusItem,
|
||||
state: {activeIndex, setActiveIndex, selectedIndex},
|
||||
} = useListboxContext();
|
||||
const autoFocusRef = useRef(true);
|
||||
const domRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// reset activeIndex on unmount
|
||||
useEffect(() => {
|
||||
return () => setActiveIndex(null);
|
||||
}, [setActiveIndex]);
|
||||
|
||||
// focus active index or menu on mount, because menu will be closed
|
||||
// on trigger keyDown and focus won't be applied to items
|
||||
useEffect(() => {
|
||||
if (autoFocusRef.current) {
|
||||
const indexToFocus = activeIndex ?? selectedIndex;
|
||||
// if no activeIndex, focus menu itself
|
||||
if (indexToFocus == null && !virtualFocus) {
|
||||
requestAnimationFrame(() => {
|
||||
domRef.current?.focus({preventScroll: true});
|
||||
});
|
||||
} else if (indexToFocus != null) {
|
||||
// wait until next frame, otherwise auto scroll might not work
|
||||
requestAnimationFrame(() => {
|
||||
focusItem('increment', indexToFocus);
|
||||
});
|
||||
}
|
||||
}
|
||||
autoFocusRef.current = false;
|
||||
}, [activeIndex, selectedIndex, focusItem, virtualFocus]);
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={virtualFocus ? undefined : -1}
|
||||
role={role}
|
||||
id={listboxId}
|
||||
className="flex-auto overflow-y-auto overscroll-contain outline-none"
|
||||
ref={domRef}
|
||||
{...domProps}
|
||||
>
|
||||
{children.length ? children : <EmptyMessage />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyMessage() {
|
||||
return (
|
||||
<div className="px-8 py-4 text-sm italic text-muted">
|
||||
<Trans message="There are no items matching your query" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
common/resources/client/ui/forms/listbox/section.tsx
Executable file
31
common/resources/client/ui/forms/listbox/section.tsx
Executable file
@@ -0,0 +1,31 @@
|
||||
import React, {ReactNode, useId} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface ListboxSectionProps {
|
||||
label?: ReactNode;
|
||||
children: React.ReactNode;
|
||||
index?: number;
|
||||
}
|
||||
export function Section({children, label, index}: ListboxSectionProps) {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
className={clsx(index !== 0 && 'border-t my-4')}
|
||||
aria-labelledby={label ? `be-select-${id}` : undefined}
|
||||
>
|
||||
{label && (
|
||||
<div
|
||||
className="block uppercase text-muted text-xs px-16 py-10"
|
||||
role="presentation"
|
||||
id={`be-select-${id}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
common/resources/client/ui/forms/listbox/types.ts
Executable file
126
common/resources/client/ui/forms/listbox/types.ts
Executable file
@@ -0,0 +1,126 @@
|
||||
import React, {MutableRefObject, ReactElement, ReactNode} from 'react';
|
||||
import {
|
||||
OffsetOptions,
|
||||
Placement,
|
||||
ReferenceType,
|
||||
UseFloatingReturn,
|
||||
VirtualElement,
|
||||
} from '@floating-ui/react-dom';
|
||||
import {
|
||||
buildListboxCollection,
|
||||
ListboxCollection,
|
||||
} from './build-listbox-collection';
|
||||
import {ListboxItemProps} from './item';
|
||||
|
||||
export type PrimitiveValue = string | number;
|
||||
type SingleSelectionProps = {
|
||||
selectionMode?: 'single';
|
||||
onSelectionChange?: (value: PrimitiveValue) => void;
|
||||
selectedValue?: PrimitiveValue | null;
|
||||
defaultSelectedValue?: PrimitiveValue;
|
||||
};
|
||||
type MultipleSelectionProps = {
|
||||
selectionMode?: 'multiple';
|
||||
onSelectionChange?: (value: PrimitiveValue[]) => void;
|
||||
selectedValue?: PrimitiveValue[];
|
||||
defaultSelectedValue?: PrimitiveValue[];
|
||||
};
|
||||
type NoneSelectionProps = {
|
||||
selectionMode?: 'none' | null;
|
||||
};
|
||||
type SelectionProps =
|
||||
| NoneSelectionProps
|
||||
| SingleSelectionProps
|
||||
| MultipleSelectionProps;
|
||||
|
||||
export interface ListBoxChildren<T> {
|
||||
items?: T[];
|
||||
children: ReactNode | ((item: T) => ReactElement<ListboxItemProps>);
|
||||
}
|
||||
|
||||
export type ListboxProps = SelectionProps & {
|
||||
role?: 'listbox' | 'menu';
|
||||
virtualFocus?: boolean;
|
||||
loopFocus?: boolean;
|
||||
autoFocusFirstItem?: boolean;
|
||||
autoUpdatePosition?: boolean;
|
||||
floatingWidth?: 'auto' | 'matchTrigger';
|
||||
floatingMinWidth?: string;
|
||||
floatingMaxHeight?: number;
|
||||
placement?: Placement;
|
||||
offset?: OffsetOptions;
|
||||
isAsync?: boolean;
|
||||
maxItems?: number;
|
||||
allowEmptySelection?: boolean;
|
||||
// fired whenever user selects an item (via click or keyboard), regardless of current selection mode
|
||||
onItemSelected?: (value: PrimitiveValue) => void;
|
||||
clearSelectionOnInputClear?: boolean;
|
||||
clearInputOnItemSelection?: boolean;
|
||||
blurReferenceOnItemSelection?: boolean;
|
||||
inputValue?: string;
|
||||
defaultInputValue?: string;
|
||||
onInputValueChange?: (value: string) => void;
|
||||
allowCustomValue?: boolean; // for combobox
|
||||
isLoading?: boolean;
|
||||
showEmptyMessage?: boolean;
|
||||
showCheckmark?: boolean;
|
||||
isOpen?: boolean;
|
||||
defaultIsOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export interface UseListboxReturn {
|
||||
handleItemSelection: (value: PrimitiveValue) => void;
|
||||
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
focusItem: (
|
||||
fallbackOperation: 'increment' | 'decrement',
|
||||
newIndex: number,
|
||||
) => void;
|
||||
|
||||
allowCustomValue: ListboxProps['allowCustomValue']; // for combobox
|
||||
loopFocus: ListboxProps['loopFocus'];
|
||||
floatingWidth: ListboxProps['floatingWidth'];
|
||||
floatingMinWidth: ListboxProps['floatingMinWidth'];
|
||||
floatingMaxHeight: ListboxProps['floatingMaxHeight'];
|
||||
showCheckmark: ListboxProps['showCheckmark'];
|
||||
// active collection, either filtered or all provided items
|
||||
collection: ListboxCollection;
|
||||
collections: ReturnType<typeof buildListboxCollection>;
|
||||
virtualFocus: ListboxProps['virtualFocus'];
|
||||
showEmptyMessage: ListboxProps['showEmptyMessage'];
|
||||
refs: {
|
||||
reference: React.MutableRefObject<HTMLElement | VirtualElement | null>;
|
||||
floating: React.MutableRefObject<HTMLElement | null>;
|
||||
};
|
||||
reference: (instance: ReferenceType | null) => void;
|
||||
floating: UseFloatingReturn['refs']['setFloating'];
|
||||
listboxId: string;
|
||||
role: ListboxProps['role'];
|
||||
listContent: (string | null)[];
|
||||
listItemsRef: MutableRefObject<(HTMLElement | null)[]>;
|
||||
positionStyle: {
|
||||
position: 'absolute' | 'fixed';
|
||||
top: string | number;
|
||||
left: string | number;
|
||||
};
|
||||
|
||||
state: {
|
||||
// currently focused or active (if virtual focus) option
|
||||
activeIndex: number | null;
|
||||
setActiveIndex: (value: number | null) => void;
|
||||
|
||||
selectedIndex?: number | null;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
selectionMode: 'single' | 'multiple' | 'none';
|
||||
selectedValues: PrimitiveValue[];
|
||||
selectValues: (value: PrimitiveValue[] | PrimitiveValue) => void;
|
||||
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
|
||||
isOpen: boolean;
|
||||
setIsOpen: (value: boolean) => void;
|
||||
|
||||
setActiveCollection: (value: 'all' | 'filtered') => void;
|
||||
};
|
||||
}
|
||||
120
common/resources/client/ui/forms/listbox/use-listbox-keyboard-navigation.ts
Executable file
120
common/resources/client/ui/forms/listbox/use-listbox-keyboard-navigation.ts
Executable file
@@ -0,0 +1,120 @@
|
||||
import React, {KeyboardEvent} from 'react';
|
||||
import {UseListboxReturn} from './types';
|
||||
|
||||
export function useListboxKeyboardNavigation({
|
||||
state: {isOpen, setIsOpen, selectedIndex, activeIndex, setInputValue},
|
||||
loopFocus,
|
||||
collection,
|
||||
focusItem,
|
||||
handleItemSelection,
|
||||
allowCustomValue,
|
||||
}: UseListboxReturn) {
|
||||
const handleTriggerKeyDown = (e: React.KeyboardEvent): true | void => {
|
||||
// ignore if dropdown is open or if event bubbled up from portal
|
||||
if (isOpen || !e.currentTarget.contains(e.target as HTMLElement)) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
focusItem('increment', selectedIndex != null ? selectedIndex : 0);
|
||||
return true;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
focusItem(
|
||||
'decrement',
|
||||
selectedIndex != null ? selectedIndex : collection.size - 1
|
||||
);
|
||||
return true;
|
||||
} else if (e.key === 'Enter' || e.key === 'Space') {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
focusItem('increment', selectedIndex != null ? selectedIndex : 0);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleListboxKeyboardNavigation = (
|
||||
e: React.KeyboardEvent
|
||||
): true | void => {
|
||||
const lastIndex = Math.max(0, collection.size - 1);
|
||||
// ignore if event bubbled up from portal, or dropdown is closed
|
||||
if (!isOpen || !e.currentTarget.contains(e.target as HTMLElement)) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (activeIndex == null) {
|
||||
focusItem('increment', 0);
|
||||
} else if (activeIndex >= lastIndex) {
|
||||
// if focus is not looping, stay on last index
|
||||
if (loopFocus) {
|
||||
focusItem('increment', 0);
|
||||
}
|
||||
} else {
|
||||
focusItem('increment', activeIndex + 1);
|
||||
}
|
||||
return true;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (activeIndex == null) {
|
||||
focusItem('decrement', lastIndex);
|
||||
} else if (activeIndex <= 0) {
|
||||
// if focus is not looping, stay on first index
|
||||
if (loopFocus) {
|
||||
focusItem('decrement', lastIndex);
|
||||
}
|
||||
} else {
|
||||
focusItem('decrement', activeIndex - 1);
|
||||
}
|
||||
return true;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
focusItem('increment', 0);
|
||||
return true;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
focusItem('decrement', lastIndex);
|
||||
return true;
|
||||
case 'Tab':
|
||||
setIsOpen(false);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleListboxSearchFieldKeydown = (
|
||||
e: KeyboardEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.key === 'Enter' && activeIndex != null && collection.size) {
|
||||
// prevent form submit when selecting item in combobox via "enter"
|
||||
e.preventDefault();
|
||||
const [value, obj] = [...collection.entries()][activeIndex];
|
||||
if (value) {
|
||||
handleItemSelection(value);
|
||||
// "onSelected" will not be called for dropdown items, because keydown
|
||||
// event will never be triggered for them in "virtualFocus" mode
|
||||
obj.element.props.onSelected?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// on escape, clear input and close dropdown
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
setIsOpen(false);
|
||||
if (!allowCustomValue) {
|
||||
setInputValue('');
|
||||
}
|
||||
}
|
||||
|
||||
const handled = handleTriggerKeyDown(e);
|
||||
if (!handled) {
|
||||
handleListboxKeyboardNavigation(e);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleTriggerKeyDown,
|
||||
handleListboxKeyboardNavigation,
|
||||
handleListboxSearchFieldKeydown,
|
||||
};
|
||||
}
|
||||
345
common/resources/client/ui/forms/listbox/use-listbox.ts
Executable file
345
common/resources/client/ui/forms/listbox/use-listbox.ts
Executable file
@@ -0,0 +1,345 @@
|
||||
import React, {Ref, useCallback, useId, useMemo, useRef, useState} from 'react';
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import {
|
||||
buildListboxCollection,
|
||||
CollectionItem,
|
||||
} from './build-listbox-collection';
|
||||
import {useFloatingPosition} from '../../overlays/floating-position';
|
||||
import {
|
||||
ListBoxChildren,
|
||||
ListboxProps,
|
||||
PrimitiveValue,
|
||||
UseListboxReturn,
|
||||
} from './types';
|
||||
import {VirtualElement} from '@floating-ui/react-dom';
|
||||
|
||||
export function useListbox<T>(
|
||||
props: ListboxProps & ListBoxChildren<T>,
|
||||
ref?: Ref<HTMLElement>,
|
||||
): UseListboxReturn {
|
||||
const {
|
||||
children,
|
||||
items,
|
||||
role = 'listbox',
|
||||
virtualFocus,
|
||||
loopFocus = false,
|
||||
autoFocusFirstItem = true,
|
||||
onItemSelected,
|
||||
clearInputOnItemSelection,
|
||||
blurReferenceOnItemSelection,
|
||||
floatingWidth = 'matchTrigger',
|
||||
floatingMinWidth,
|
||||
floatingMaxHeight,
|
||||
offset,
|
||||
placement,
|
||||
showCheckmark,
|
||||
showEmptyMessage,
|
||||
maxItems,
|
||||
isAsync,
|
||||
allowCustomValue,
|
||||
clearSelectionOnInputClear,
|
||||
} = props;
|
||||
const selectionMode = props.selectionMode || 'none';
|
||||
const id = useId();
|
||||
const listboxId = `${id}-listbox`;
|
||||
|
||||
// controlled state for text input (if in combobox mode)
|
||||
const [inputValue, setInputValue] = useControlledState(
|
||||
props.inputValue,
|
||||
props.defaultInputValue || '',
|
||||
props.onInputValueChange,
|
||||
);
|
||||
|
||||
// mostly for combobox, so can show all collection items on dropdown icon click, even if user has filtered via input
|
||||
const [activeCollection, setActiveCollection] = useState<'all' | 'filtered'>(
|
||||
'all',
|
||||
);
|
||||
|
||||
const collections = buildListboxCollection({
|
||||
children,
|
||||
items,
|
||||
// don't filter on client side if async, it will already be filtered on server
|
||||
inputValue: isAsync ? undefined : inputValue,
|
||||
maxItems,
|
||||
});
|
||||
const collection =
|
||||
activeCollection === 'all'
|
||||
? collections.collection
|
||||
: collections.filteredCollection;
|
||||
|
||||
// items for keyboard navigation
|
||||
const listItemsRef = useRef<Array<HTMLElement | null>>([]);
|
||||
|
||||
// plain text labels for typeahead
|
||||
const listContent: (string | null)[] = useMemo(() => {
|
||||
return [...collection.values()].map(o =>
|
||||
o.isDisabled ? null : o.textLabel,
|
||||
);
|
||||
}, [collection]);
|
||||
|
||||
// state for currently selected values (always array, even in single selection mode)
|
||||
const {selectedValues, selectValues} = useControlledSelection(props);
|
||||
|
||||
const [isOpen, setIsOpen] = useControlledState(
|
||||
props.isOpen,
|
||||
props.defaultIsOpen,
|
||||
props.onOpenChange,
|
||||
);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
|
||||
// handle listbox positioning relative to trigger
|
||||
const floatingProps = useFloatingPosition({
|
||||
floatingWidth,
|
||||
ref,
|
||||
placement,
|
||||
offset,
|
||||
maxHeight: floatingMaxHeight ?? 420,
|
||||
// don't shift floating menu on the sides of combobox, otherwise input might get obscured
|
||||
shiftCrossAxis: !virtualFocus,
|
||||
});
|
||||
const {refs, strategy, x, y} = floatingProps;
|
||||
|
||||
// handle selection state for syncing with active index in keyboard navigation
|
||||
const selectedOption =
|
||||
selectionMode === 'none' ? undefined : collection.get(selectedValues[0]);
|
||||
const selectedIndex =
|
||||
selectionMode === 'none' ? undefined : selectedOption?.index;
|
||||
const setSelectedIndex = (index: number) => {
|
||||
if (selectionMode !== 'none') {
|
||||
const item = [...collection.values()][index];
|
||||
if (item) {
|
||||
selectValues(item.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// focus and scroll to specified index, in both virtual and regular mode.
|
||||
// will also skip disabled indices and focus next or previous non-disabled index instead
|
||||
const focusItem = useCallback(
|
||||
(fallbackOperation: 'increment' | 'decrement', newIndex: number) => {
|
||||
const items = [...collection.values()];
|
||||
const allItemsDisabled = !items.find(i => !i.isDisabled);
|
||||
const lastIndex = collection.size - 1;
|
||||
|
||||
// invalid index
|
||||
if (
|
||||
newIndex == null ||
|
||||
!collection.size ||
|
||||
newIndex > lastIndex ||
|
||||
newIndex < 0 ||
|
||||
allItemsDisabled
|
||||
) {
|
||||
setActiveIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// get next or previous non-disabled item
|
||||
newIndex = getNonDisabledIndex(
|
||||
items,
|
||||
newIndex,
|
||||
loopFocus,
|
||||
fallbackOperation,
|
||||
);
|
||||
|
||||
setActiveIndex(newIndex);
|
||||
|
||||
if (virtualFocus) {
|
||||
listItemsRef.current[newIndex]?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
} else {
|
||||
listItemsRef.current[newIndex]?.focus();
|
||||
}
|
||||
},
|
||||
[collection, virtualFocus, loopFocus],
|
||||
);
|
||||
|
||||
const onInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
|
||||
setActiveCollection(e.target.value.trim() ? 'filtered' : 'all');
|
||||
|
||||
if (e.target.value) {
|
||||
setIsOpen(true);
|
||||
} else if (clearSelectionOnInputClear) {
|
||||
// deselect currently selected option if user fully clears the input
|
||||
selectValues('');
|
||||
}
|
||||
|
||||
if (autoFocusFirstItem && activeIndex == null) {
|
||||
focusItem('increment', 0);
|
||||
} else {
|
||||
setActiveIndex(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
setInputValue,
|
||||
setIsOpen,
|
||||
setActiveCollection,
|
||||
selectValues,
|
||||
clearSelectionOnInputClear,
|
||||
focusItem,
|
||||
autoFocusFirstItem,
|
||||
activeIndex,
|
||||
],
|
||||
);
|
||||
|
||||
const handleItemSelection = (value: PrimitiveValue) => {
|
||||
const reference = refs.reference.current as
|
||||
| HTMLElement
|
||||
| VirtualElement
|
||||
| null;
|
||||
if (selectionMode !== 'none') {
|
||||
selectValues(value);
|
||||
} else {
|
||||
if (reference && 'focus' in reference) {
|
||||
reference.focus();
|
||||
}
|
||||
}
|
||||
// is combobox
|
||||
if (virtualFocus) {
|
||||
setInputValue(clearInputOnItemSelection ? '' : `${value}`);
|
||||
if (blurReferenceOnItemSelection && reference && 'blur' in reference) {
|
||||
reference.blur();
|
||||
}
|
||||
}
|
||||
setActiveCollection('all');
|
||||
setIsOpen(false);
|
||||
onItemSelected?.(value);
|
||||
// make sure "onItemSelected" callback has a chance to use activeIndex value, before clearing it
|
||||
setActiveIndex(null);
|
||||
};
|
||||
|
||||
return {
|
||||
// even handlers
|
||||
handleItemSelection,
|
||||
onInputChange,
|
||||
loopFocus,
|
||||
|
||||
// config
|
||||
floatingWidth,
|
||||
floatingMinWidth,
|
||||
floatingMaxHeight,
|
||||
showCheckmark,
|
||||
collection,
|
||||
collections,
|
||||
virtualFocus,
|
||||
focusItem,
|
||||
showEmptyMessage: showEmptyMessage && !!inputValue,
|
||||
allowCustomValue,
|
||||
|
||||
// floating ui
|
||||
refs,
|
||||
reference: floatingProps.reference,
|
||||
floating: refs.setFloating,
|
||||
positionStyle: {
|
||||
position: strategy,
|
||||
top: y ?? '',
|
||||
left: x ?? '',
|
||||
},
|
||||
|
||||
listContent,
|
||||
listItemsRef,
|
||||
listboxId,
|
||||
role,
|
||||
|
||||
state: {
|
||||
// currently focused or active (if virtual focus) option
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
selectionMode,
|
||||
selectedValues,
|
||||
selectValues,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
setActiveCollection,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getNonDisabledIndex(
|
||||
items: CollectionItem<unknown>[],
|
||||
newIndex: number,
|
||||
loopFocus: boolean,
|
||||
operation: 'increment' | 'decrement',
|
||||
) {
|
||||
const lastIndex = items.length - 1;
|
||||
while (items[newIndex]?.isDisabled) {
|
||||
if (operation === 'increment') {
|
||||
newIndex++;
|
||||
if (newIndex >= lastIndex) {
|
||||
// loop from the start, if end reached
|
||||
if (loopFocus) {
|
||||
newIndex = 0;
|
||||
// if focus is not looping, stay on the previous index
|
||||
} else {
|
||||
return newIndex - 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newIndex--;
|
||||
// loop from the end, if start reached
|
||||
if (newIndex < 0) {
|
||||
if (loopFocus) {
|
||||
newIndex = lastIndex;
|
||||
// if focus is not looping, stay on the previous index
|
||||
} else {
|
||||
return newIndex + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newIndex;
|
||||
}
|
||||
|
||||
function useControlledSelection(props: ListboxProps) {
|
||||
const {selectionMode, allowEmptySelection} = props;
|
||||
const selectionEnabled =
|
||||
selectionMode === 'single' || selectionMode === 'multiple';
|
||||
|
||||
const [stateValues, setStateValues] = useControlledState<any>(
|
||||
!selectionEnabled ? undefined : props.selectedValue,
|
||||
!selectionEnabled ? undefined : props.defaultSelectedValue,
|
||||
!selectionEnabled ? undefined : props.onSelectionChange,
|
||||
);
|
||||
|
||||
const selectedValues = useMemo(() => {
|
||||
// allow specifying null as selected value, but not undefined
|
||||
if (typeof stateValues === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(stateValues) ? stateValues : [stateValues];
|
||||
}, [stateValues]);
|
||||
|
||||
const selectValues = useCallback(
|
||||
(mixedValue: PrimitiveValue | PrimitiveValue[] | null) => {
|
||||
const newValues = Array.isArray(mixedValue) ? mixedValue : [mixedValue];
|
||||
if (selectionMode === 'single') {
|
||||
setStateValues(newValues[0]);
|
||||
} else {
|
||||
newValues.forEach(newValue => {
|
||||
const index = selectedValues.indexOf(newValue);
|
||||
if (index === -1) {
|
||||
selectedValues.push(newValue);
|
||||
setStateValues([...selectedValues]);
|
||||
} else if (selectedValues.length > 1 || allowEmptySelection) {
|
||||
selectedValues.splice(index, 1);
|
||||
setStateValues([...selectedValues]);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
[allowEmptySelection, selectedValues, selectionMode, setStateValues],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedValues,
|
||||
selectValues,
|
||||
};
|
||||
}
|
||||
99
common/resources/client/ui/forms/listbox/use-type-select.ts
Executable file
99
common/resources/client/ui/forms/listbox/use-type-select.ts
Executable file
@@ -0,0 +1,99 @@
|
||||
import React, {useRef} from 'react';
|
||||
import {useCollator} from '../../../i18n/use-collator';
|
||||
|
||||
interface UseTypeSelectReturn {
|
||||
findMatchingItem: (
|
||||
e: React.KeyboardEvent,
|
||||
listContent: (string | null)[],
|
||||
fromIndex?: number | null
|
||||
) => number | null;
|
||||
}
|
||||
|
||||
interface SearchState {
|
||||
search: string;
|
||||
timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
}
|
||||
|
||||
export function useTypeSelect(): UseTypeSelectReturn {
|
||||
const collator = useCollator({usage: 'search', sensitivity: 'base'});
|
||||
const state = useRef<SearchState>({
|
||||
search: '',
|
||||
timeout: undefined,
|
||||
}).current;
|
||||
|
||||
const getMatchingIndex = (
|
||||
listContent: (string | null)[],
|
||||
fromIndex?: number | null
|
||||
) => {
|
||||
let index = fromIndex ?? 0;
|
||||
while (index != null) {
|
||||
const item = listContent[index];
|
||||
const substring = item?.slice(0, state.search.length);
|
||||
|
||||
if (substring && collator.compare(substring, state.search) === 0) {
|
||||
return index;
|
||||
}
|
||||
|
||||
if (index < listContent.length - 1) {
|
||||
index++;
|
||||
// reached the end of list
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const findMatchingItem: UseTypeSelectReturn['findMatchingItem'] = (
|
||||
e,
|
||||
listContent,
|
||||
fromIndex = 0
|
||||
) => {
|
||||
const character = getStringForKey(e.key);
|
||||
if (!character || e.ctrlKey || e.metaKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Do not propagate the Spacebar event if it's meant to be part of the search.
|
||||
// When we time out, the search term becomes empty, hence the check on length.
|
||||
// Trimming is to account for the case of pressing the Spacebar more than once,
|
||||
// which should cycle through the selection/deselection of the focused item.
|
||||
if (character === ' ' && state.search.trim().length > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
state.search += character;
|
||||
|
||||
// Use the delegate to find a key to focus.
|
||||
// Prioritize items after the currently focused item, falling back to searching the whole list.
|
||||
let index = getMatchingIndex(listContent, fromIndex);
|
||||
|
||||
// If no key found, search from the top.
|
||||
if (index == null) {
|
||||
index = getMatchingIndex(listContent, 0);
|
||||
}
|
||||
|
||||
clearTimeout(state.timeout);
|
||||
state.timeout = setTimeout(() => {
|
||||
state.search = '';
|
||||
}, 500);
|
||||
|
||||
return index ?? null;
|
||||
};
|
||||
|
||||
return {findMatchingItem};
|
||||
}
|
||||
|
||||
function getStringForKey(key: string) {
|
||||
// If the key is of length 1, it is an ASCII value.
|
||||
// Otherwise, if there are no ASCII characters in the key name,
|
||||
// it is a Unicode character.
|
||||
// See https://www.w3.org/TR/uievents-key/
|
||||
if (key.length === 1 || !/^[A-Z]/i.test(key)) {
|
||||
return key;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
243
common/resources/client/ui/forms/normalized-model-field.tsx
Executable file
243
common/resources/client/ui/forms/normalized-model-field.tsx
Executable file
@@ -0,0 +1,243 @@
|
||||
import React, {ReactNode, useRef, useState} from 'react';
|
||||
import {useTrans} from '../../i18n/use-trans';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {Avatar} from '../images/avatar';
|
||||
import {Tooltip} from '../tooltip/tooltip';
|
||||
import {IconButton} from '../buttons/icon-button';
|
||||
import {EditIcon} from '../../icons/material/Edit';
|
||||
import {message} from '../../i18n/message';
|
||||
import {Item} from './listbox/item';
|
||||
import {useController, useFormContext} from 'react-hook-form';
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import {getInputFieldClassNames} from './input-field/get-input-field-class-names';
|
||||
import clsx from 'clsx';
|
||||
import {Skeleton} from '../skeleton/skeleton';
|
||||
import {useNormalizedModels} from '../../users/queries/use-normalized-models';
|
||||
import {useNormalizedModel} from '../../users/queries/use-normalized-model';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '../animation/opacity-animation';
|
||||
import {Select} from '@common/ui/forms/select/select';
|
||||
import {MessageDescriptor} from '@common/i18n/message-descriptor';
|
||||
import {BaseFieldProps} from '@common/ui/forms/input-field/base-field-props';
|
||||
|
||||
interface NormalizedModelFieldProps {
|
||||
label?: ReactNode;
|
||||
className?: string;
|
||||
background?: BaseFieldProps['background'];
|
||||
value?: string | number;
|
||||
placeholder?: MessageDescriptor;
|
||||
searchPlaceholder?: MessageDescriptor;
|
||||
defaultValue?: string | number;
|
||||
onChange?: (value: string | number) => void;
|
||||
invalid?: boolean;
|
||||
errorMessage?: string;
|
||||
description?: ReactNode;
|
||||
autoFocus?: boolean;
|
||||
queryParams?: Record<string, string>;
|
||||
endpoint: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
export function NormalizedModelField({
|
||||
label,
|
||||
className,
|
||||
background,
|
||||
value,
|
||||
defaultValue = '',
|
||||
placeholder = message('Select item...'),
|
||||
searchPlaceholder = message('Find an item...'),
|
||||
onChange,
|
||||
description,
|
||||
errorMessage,
|
||||
invalid,
|
||||
autoFocus,
|
||||
queryParams,
|
||||
endpoint,
|
||||
disabled,
|
||||
required,
|
||||
}: NormalizedModelFieldProps) {
|
||||
const inputRef = useRef<HTMLButtonElement>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [selectedValue, setSelectedValue] = useControlledState(
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
);
|
||||
const query = useNormalizedModels(endpoint, {
|
||||
query: inputValue,
|
||||
...queryParams,
|
||||
});
|
||||
const {trans} = useTrans();
|
||||
|
||||
const fieldClassNames = getInputFieldClassNames({size: 'md'});
|
||||
|
||||
if (selectedValue) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={fieldClassNames.label}>{label}</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-input border p-8',
|
||||
background,
|
||||
invalid && 'border-danger',
|
||||
)}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<SelectedModelPreview
|
||||
disabled={disabled}
|
||||
endpoint={endpoint}
|
||||
modelId={selectedValue}
|
||||
queryParams={queryParams}
|
||||
onEditClick={() => {
|
||||
setSelectedValue('');
|
||||
setInputValue('');
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.click();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{description && !errorMessage && (
|
||||
<div className={fieldClassNames.description}>{description}</div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<div className={fieldClassNames.error}>{errorMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={className}
|
||||
showSearchField
|
||||
invalid={invalid}
|
||||
errorMessage={errorMessage}
|
||||
description={description}
|
||||
color="white"
|
||||
isAsync
|
||||
background={background}
|
||||
placeholder={trans(placeholder)}
|
||||
searchPlaceholder={trans(searchPlaceholder)}
|
||||
label={label}
|
||||
isLoading={query.isFetching}
|
||||
items={query.data?.results}
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={setInputValue}
|
||||
selectionMode="single"
|
||||
selectedValue={selectedValue}
|
||||
onSelectionChange={setSelectedValue}
|
||||
ref={inputRef}
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
>
|
||||
{model => (
|
||||
<Item
|
||||
value={model.id}
|
||||
key={model.id}
|
||||
description={model.description}
|
||||
startIcon={<Avatar src={model.image} />}
|
||||
>
|
||||
{model.name}
|
||||
</Item>
|
||||
)}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectedModelPreviewProps {
|
||||
modelId: string | number;
|
||||
selectedValue?: number | string;
|
||||
onEditClick?: () => void;
|
||||
endpoint?: string;
|
||||
disabled?: boolean;
|
||||
queryParams?: NormalizedModelFieldProps['queryParams'];
|
||||
}
|
||||
function SelectedModelPreview({
|
||||
modelId,
|
||||
onEditClick,
|
||||
endpoint,
|
||||
disabled,
|
||||
queryParams,
|
||||
}: SelectedModelPreviewProps) {
|
||||
const {data, isLoading} = useNormalizedModel(
|
||||
`${endpoint}/${modelId}`,
|
||||
queryParams,
|
||||
);
|
||||
|
||||
if (isLoading || !data?.model) {
|
||||
return <LoadingSkeleton key="skeleton" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<m.div
|
||||
className={clsx(
|
||||
'flex items-center gap-10',
|
||||
disabled && 'pointer-events-none cursor-not-allowed text-disabled',
|
||||
)}
|
||||
key="preview"
|
||||
{...opacityAnimation}
|
||||
>
|
||||
{data.model.image && <Avatar src={data.model.image} />}
|
||||
<div>
|
||||
<div className="text-sm leading-4">{data.model.name}</div>
|
||||
<div className="text-xs text-muted">{data.model.description}</div>
|
||||
</div>
|
||||
<Tooltip label={<Trans message="Change item" />}>
|
||||
<IconButton
|
||||
className="ml-auto text-muted"
|
||||
size="sm"
|
||||
onClick={onEditClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<m.div className="flex items-center gap-10" {...opacityAnimation}>
|
||||
<Skeleton variant="rect" size="w-32 h-32" />
|
||||
<div className="max-h-[36px] flex-auto">
|
||||
<Skeleton className="text-xs" />
|
||||
<Skeleton className="max-h-8 text-xs" />
|
||||
</div>
|
||||
<Skeleton variant="icon" size="w-24 h-24" />
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormNormalizedModelFieldProps extends NormalizedModelFieldProps {
|
||||
name: string;
|
||||
}
|
||||
export function FormNormalizedModelField({
|
||||
name,
|
||||
...fieldProps
|
||||
}: FormNormalizedModelFieldProps) {
|
||||
const {clearErrors} = useFormContext();
|
||||
const {
|
||||
field: {onChange, value = ''},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name,
|
||||
});
|
||||
|
||||
return (
|
||||
<NormalizedModelField
|
||||
value={value}
|
||||
onChange={value => {
|
||||
onChange(value);
|
||||
clearErrors(name);
|
||||
}}
|
||||
invalid={invalid}
|
||||
errorMessage={error?.message}
|
||||
{...fieldProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1
common/resources/client/ui/forms/orientation.ts
Executable file
1
common/resources/client/ui/forms/orientation.ts
Executable file
@@ -0,0 +1 @@
|
||||
export type Orientation = 'horizontal' | 'vertical';
|
||||
103
common/resources/client/ui/forms/radio-group/radio-group.tsx
Executable file
103
common/resources/client/ui/forms/radio-group/radio-group.tsx
Executable file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
ReactNode,
|
||||
useId,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {Orientation} from '../orientation';
|
||||
import {RadioProps} from './radio';
|
||||
import {getInputFieldClassNames} from '../input-field/get-input-field-class-names';
|
||||
|
||||
export interface RadioGroupProps {
|
||||
children: ReactNode;
|
||||
orientation?: Orientation;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
label?: ReactNode;
|
||||
disabled?: boolean;
|
||||
name?: string;
|
||||
errorMessage?: ReactNode;
|
||||
description?: ReactNode;
|
||||
invalid?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
export const RadioGroup = forwardRef<HTMLFieldSetElement, RadioGroupProps>(
|
||||
(props, ref) => {
|
||||
const style = getInputFieldClassNames(props);
|
||||
const {
|
||||
label,
|
||||
children,
|
||||
size,
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
disabled,
|
||||
required,
|
||||
invalid,
|
||||
errorMessage,
|
||||
description,
|
||||
} = props;
|
||||
|
||||
const labelProps = {};
|
||||
const id = useId();
|
||||
const name = props.name || id;
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
aria-describedby={description ? `${id}-description` : undefined}
|
||||
ref={ref}
|
||||
className={clsx('text-left', className)}
|
||||
>
|
||||
{label && (
|
||||
<legend className={style.label} {...labelProps}>
|
||||
{label}
|
||||
</legend>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex',
|
||||
label ? 'mt-6' : 'mt-0',
|
||||
orientation === 'vertical' ? 'flex-col gap-10' : 'flex-row gap-16'
|
||||
)}
|
||||
>
|
||||
{Children.map(children, child => {
|
||||
if (isValidElement<RadioProps>(child)) {
|
||||
return cloneElement<RadioProps>(child, {
|
||||
name,
|
||||
size,
|
||||
invalid: child.props.invalid || invalid || undefined,
|
||||
disabled: child.props.disabled || disabled,
|
||||
required: child.props.required || required,
|
||||
});
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{description && !errorMessage && (
|
||||
<div className={style.description} id={`${id}-description`}>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && <div className={style.error}>{errorMessage}</div>}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface FormRadioGroupProps extends RadioGroupProps {
|
||||
name: string;
|
||||
}
|
||||
export function FormRadioGroup({children, ...props}: FormRadioGroupProps) {
|
||||
const {
|
||||
fieldState: {error},
|
||||
} = useController({
|
||||
name: props.name!,
|
||||
});
|
||||
return (
|
||||
<RadioGroup errorMessage={error?.message} {...props}>
|
||||
{children}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
85
common/resources/client/ui/forms/radio-group/radio.tsx
Executable file
85
common/resources/client/ui/forms/radio-group/radio.tsx
Executable file
@@ -0,0 +1,85 @@
|
||||
import React, {ComponentPropsWithoutRef, forwardRef} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {mergeProps, useObjectRef} from '@react-aria/utils';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {AutoFocusProps, useAutoFocus} from '../../focus/use-auto-focus';
|
||||
|
||||
type RadioSize = 'xs' | 'sm' | 'md' | 'lg' | undefined;
|
||||
|
||||
export interface RadioProps
|
||||
extends AutoFocusProps,
|
||||
Omit<ComponentPropsWithoutRef<'input'>, 'size'> {
|
||||
size?: RadioSize;
|
||||
value: string;
|
||||
invalid?: boolean;
|
||||
isFirst?: boolean;
|
||||
}
|
||||
export const Radio = forwardRef<HTMLInputElement, RadioProps>((props, ref) => {
|
||||
const {children, autoFocus, size, invalid, isFirst, ...domProps} = props;
|
||||
|
||||
const inputRef = useObjectRef(ref);
|
||||
useAutoFocus({autoFocus}, inputRef);
|
||||
|
||||
const sizeClassNames = getSizeClassNames(size);
|
||||
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
'inline-flex gap-8 select-none items-center whitespace-nowrap align-middle',
|
||||
sizeClassNames.label,
|
||||
props.disabled && 'text-disabled pointer-events-none',
|
||||
props.invalid && 'text-danger'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className={clsx(
|
||||
'focus-visible:ring outline-none',
|
||||
'rounded-full transition-button border-2 appearance-none',
|
||||
'border-text-muted disabled:border-disabled-fg checked:border-primary checked:hover:border-primary-dark',
|
||||
'before:bg-primary disabled:before:bg-disabled-fg before:hover:bg-primary-dark',
|
||||
'before:h-full before:w-full before:block before:rounded-full before:scale-10 before:opacity-0 before:transition before:duration-200',
|
||||
'checked:before:scale-[.65] checked:before:opacity-100',
|
||||
sizeClassNames.circle
|
||||
)}
|
||||
ref={inputRef}
|
||||
{...domProps}
|
||||
/>
|
||||
{children && <span>{children}</span>}
|
||||
</label>
|
||||
);
|
||||
});
|
||||
|
||||
export function FormRadio(props: RadioProps) {
|
||||
const {
|
||||
field: {onChange, onBlur, value, ref},
|
||||
fieldState: {invalid},
|
||||
} = useController({
|
||||
name: props.name!,
|
||||
});
|
||||
|
||||
const formProps: Partial<RadioProps> = {
|
||||
onChange,
|
||||
onBlur,
|
||||
checked: props.value === value,
|
||||
invalid: props.invalid || invalid,
|
||||
};
|
||||
|
||||
return <Radio ref={ref} {...mergeProps(formProps, props)} />;
|
||||
}
|
||||
|
||||
function getSizeClassNames(size?: RadioSize): {
|
||||
circle: string;
|
||||
label: string;
|
||||
} {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return {circle: 'h-12 w-12', label: 'text-xs'};
|
||||
case 'sm':
|
||||
return {circle: 'h-16 w-16', label: 'text-sm'};
|
||||
case 'lg':
|
||||
return {circle: 'h-24 w-24', label: 'text-lg'};
|
||||
default:
|
||||
return {circle: 'h-20 w-20', label: 'text-base'};
|
||||
}
|
||||
}
|
||||
41
common/resources/client/ui/forms/segmented-radio-group/active-indicator.tsx
Executable file
41
common/resources/client/ui/forms/segmented-radio-group/active-indicator.tsx
Executable file
@@ -0,0 +1,41 @@
|
||||
import {RefObject, useEffect, useState} from 'react';
|
||||
import {m} from 'framer-motion';
|
||||
|
||||
interface ActiveIndicatorProps {
|
||||
selectedValue?: string;
|
||||
labelsRef: RefObject<Record<string, HTMLLabelElement>>;
|
||||
}
|
||||
export function ActiveIndicator({
|
||||
selectedValue,
|
||||
labelsRef,
|
||||
}: ActiveIndicatorProps) {
|
||||
const [style, setStyle] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
left: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedValue != null && labelsRef.current) {
|
||||
const el = labelsRef.current[selectedValue];
|
||||
if (!el) return;
|
||||
setStyle({
|
||||
width: el.offsetWidth,
|
||||
height: el.offsetHeight,
|
||||
left: el.offsetLeft,
|
||||
});
|
||||
}
|
||||
}, [setStyle, selectedValue, labelsRef]);
|
||||
|
||||
if (!style) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<m.div
|
||||
animate={style}
|
||||
initial={false}
|
||||
className="bg-paper shadow rounded absolute z-10 pointer-events-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useId,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {RadioGroupProps} from '../radio-group/radio-group';
|
||||
import {SegmentedRadioProps} from './segmented-radio';
|
||||
import {ActiveIndicator} from './active-indicator';
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface SegmentedRadioGroupProps
|
||||
extends Omit<RadioGroupProps, 'orientation'> {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
defaultValue?: string;
|
||||
width?: string;
|
||||
}
|
||||
export const SegmentedRadioGroup = forwardRef<
|
||||
HTMLFieldSetElement,
|
||||
SegmentedRadioGroupProps
|
||||
>((props, ref) => {
|
||||
const {children, size, className} = props;
|
||||
|
||||
const id = useId();
|
||||
const name = props.name || id;
|
||||
|
||||
const labelsRef = useRef<Record<string, HTMLLabelElement>>({});
|
||||
const [selectedValue, setSelectedValue] = useControlledState(
|
||||
props.value,
|
||||
props.defaultValue,
|
||||
props.onChange,
|
||||
);
|
||||
|
||||
return (
|
||||
<fieldset ref={ref} className={clsx(className, props.width ?? 'w-min')}>
|
||||
<div className="relative isolate flex rounded bg-chip p-4">
|
||||
<ActiveIndicator selectedValue={selectedValue} labelsRef={labelsRef} />
|
||||
{Children.map(children, (child, index) => {
|
||||
if (isValidElement<SegmentedRadioProps>(child)) {
|
||||
return cloneElement<SegmentedRadioProps>(child, {
|
||||
isFirst: index === 0,
|
||||
name,
|
||||
size,
|
||||
onChange: e => {
|
||||
setSelectedValue(e.target.value);
|
||||
child.props.onChange?.(e);
|
||||
},
|
||||
labelRef: el => {
|
||||
if (el) {
|
||||
labelsRef.current[child.props.value] = el;
|
||||
}
|
||||
},
|
||||
isSelected: selectedValue === child.props.value,
|
||||
});
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
});
|
||||
65
common/resources/client/ui/forms/segmented-radio-group/segmented-radio.tsx
Executable file
65
common/resources/client/ui/forms/segmented-radio-group/segmented-radio.tsx
Executable file
@@ -0,0 +1,65 @@
|
||||
import React, {forwardRef, Ref} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useObjectRef} from '@react-aria/utils';
|
||||
import {InputSize} from '../input-field/input-size';
|
||||
import {useAutoFocus} from '../../focus/use-auto-focus';
|
||||
import {RadioProps} from '../radio-group/radio';
|
||||
|
||||
export interface SegmentedRadioProps extends RadioProps {
|
||||
labelRef?: Ref<HTMLLabelElement>;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
export const SegmentedRadio = forwardRef<HTMLInputElement, SegmentedRadioProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
children,
|
||||
autoFocus,
|
||||
size,
|
||||
invalid,
|
||||
isFirst,
|
||||
labelRef,
|
||||
isSelected,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
const inputRef = useObjectRef(ref);
|
||||
useAutoFocus({autoFocus}, inputRef);
|
||||
|
||||
const sizeClassNames = getSizeClassNames(size);
|
||||
|
||||
return (
|
||||
<label
|
||||
ref={labelRef}
|
||||
className={clsx(
|
||||
'relative z-20 inline-flex flex-auto cursor-pointer select-none items-center justify-center gap-8 whitespace-nowrap align-middle font-medium transition-colors hover:text-main',
|
||||
isSelected ? 'text-main' : 'text-muted',
|
||||
!isFirst && '',
|
||||
sizeClassNames,
|
||||
props.disabled && 'pointer-events-none text-disabled',
|
||||
props.invalid && 'text-danger',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className="pointer-events-none absolute left-0 top-0 h-full w-full appearance-none rounded focus-visible:outline"
|
||||
ref={inputRef}
|
||||
{...domProps}
|
||||
/>
|
||||
{children && <span>{children}</span>}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function getSizeClassNames(size?: InputSize): string {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'px-6 py-3 text-xs';
|
||||
case 'sm':
|
||||
return 'px-10 py-5 text-sm';
|
||||
case 'lg':
|
||||
return 'px-16 py-6 text-lg';
|
||||
default:
|
||||
return 'px-16 py-8 text-sm';
|
||||
}
|
||||
}
|
||||
23
common/resources/client/ui/forms/select/native-select.tsx
Executable file
23
common/resources/client/ui/forms/select/native-select.tsx
Executable file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import {getInputFieldClassNames} from '../input-field/get-input-field-class-names';
|
||||
import {BaseFieldProps} from '../input-field/base-field-props';
|
||||
|
||||
interface Props
|
||||
extends BaseFieldProps,
|
||||
Omit<React.ComponentPropsWithoutRef<'select'>, 'size'> {}
|
||||
export function NativeSelect(props: Props) {
|
||||
const style = getInputFieldClassNames(props);
|
||||
const {label, id, children, size, ...other} = {...props};
|
||||
return (
|
||||
<div className={style.wrapper}>
|
||||
{label && (
|
||||
<label className={style.label} htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select id={id} className={style.input} {...other}>
|
||||
{children}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
common/resources/client/ui/forms/select/select.tsx
Executable file
254
common/resources/client/ui/forms/select/select.tsx
Executable file
@@ -0,0 +1,254 @@
|
||||
import React, {ReactElement, Ref, RefObject} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import {getInputFieldClassNames} from '../input-field/get-input-field-class-names';
|
||||
import {Field} from '../input-field/field';
|
||||
import {BaseFieldPropsWithDom} from '../input-field/base-field-props';
|
||||
import {useListbox} from '../listbox/use-listbox';
|
||||
import {useField} from '../input-field/use-field';
|
||||
import {Item} from '../listbox/item';
|
||||
import {Section} from '../listbox/section';
|
||||
import {Listbox} from '../listbox/listbox';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {useListboxKeyboardNavigation} from '../listbox/use-listbox-keyboard-navigation';
|
||||
import {useTypeSelect} from '../listbox/use-type-select';
|
||||
import {ListBoxChildren, ListboxProps} from '../listbox/types';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
import {TextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {SearchIcon} from '@common/icons/material/Search';
|
||||
import {ComboboxEndAdornment} from '@common/ui/forms/combobox/combobox-end-adornment';
|
||||
|
||||
export type SelectProps<T extends object> = Omit<
|
||||
BaseFieldPropsWithDom<HTMLButtonElement>,
|
||||
'value'
|
||||
> &
|
||||
ListboxProps &
|
||||
ListBoxChildren<T> & {
|
||||
hideCaret?: boolean;
|
||||
selectionMode: 'single';
|
||||
minWidth?: string;
|
||||
searchPlaceholder?: string;
|
||||
showSearchField?: boolean;
|
||||
valueClassName?: string;
|
||||
};
|
||||
function Select<T extends object>(
|
||||
props: SelectProps<T>,
|
||||
ref: Ref<HTMLButtonElement>,
|
||||
) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const {
|
||||
hideCaret,
|
||||
placeholder = <Trans message="Select an option..." />,
|
||||
selectedValue,
|
||||
onItemSelected,
|
||||
onOpenChange,
|
||||
onInputValueChange,
|
||||
onSelectionChange,
|
||||
selectionMode,
|
||||
minWidth = 'min-w-128',
|
||||
children,
|
||||
searchPlaceholder,
|
||||
showEmptyMessage,
|
||||
showSearchField,
|
||||
defaultInputValue,
|
||||
inputValue: userInputValue,
|
||||
isLoading,
|
||||
isAsync,
|
||||
valueClassName,
|
||||
floatingWidth = isMobile ? 'auto' : 'matchTrigger',
|
||||
...inputFieldProps
|
||||
} = props;
|
||||
|
||||
const listbox = useListbox(
|
||||
{
|
||||
...props,
|
||||
clearInputOnItemSelection: true,
|
||||
showEmptyMessage: showEmptyMessage || showSearchField,
|
||||
floatingWidth,
|
||||
selectionMode: 'single',
|
||||
role: 'listbox',
|
||||
virtualFocus: showSearchField,
|
||||
},
|
||||
ref,
|
||||
);
|
||||
const {
|
||||
state: {
|
||||
selectedValues,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
activeIndex,
|
||||
setSelectedIndex,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
},
|
||||
collections,
|
||||
focusItem,
|
||||
listboxId,
|
||||
reference,
|
||||
refs,
|
||||
listContent,
|
||||
onInputChange,
|
||||
} = listbox;
|
||||
|
||||
const {fieldProps, inputProps} = useField({
|
||||
...inputFieldProps,
|
||||
focusRef: refs.reference as RefObject<HTMLButtonElement>,
|
||||
});
|
||||
|
||||
const selectedOption = collections.collection.get(selectedValues[0]);
|
||||
const content = selectedOption ? (
|
||||
<span className="flex items-center gap-10">
|
||||
{selectedOption.element.props.startIcon}
|
||||
<span
|
||||
className={clsx(
|
||||
'overflow-hidden overflow-ellipsis whitespace-nowrap',
|
||||
valueClassName,
|
||||
)}
|
||||
>
|
||||
{selectedOption.element.props.children}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="italic">{placeholder}</span>
|
||||
);
|
||||
|
||||
const fieldClassNames = getInputFieldClassNames({
|
||||
...props,
|
||||
endAdornment: true,
|
||||
});
|
||||
|
||||
const {
|
||||
handleTriggerKeyDown,
|
||||
handleListboxKeyboardNavigation,
|
||||
handleListboxSearchFieldKeydown,
|
||||
} = useListboxKeyboardNavigation(listbox);
|
||||
|
||||
const {findMatchingItem} = useTypeSelect();
|
||||
|
||||
// focus matching item when user types, if dropdown is open
|
||||
const handleListboxTypeSelect = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
const i = findMatchingItem(e, listContent, activeIndex);
|
||||
if (i != null) {
|
||||
focusItem('increment', i);
|
||||
}
|
||||
};
|
||||
|
||||
// select matching item when user types, if dropdown is closed
|
||||
const handleTriggerTypeSelect = (e: React.KeyboardEvent) => {
|
||||
if (isOpen) return undefined;
|
||||
const i = findMatchingItem(e, listContent, activeIndex);
|
||||
if (i != null) {
|
||||
setSelectedIndex(i);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
listbox={listbox}
|
||||
onKeyDownCapture={!showSearchField ? handleListboxTypeSelect : undefined}
|
||||
onKeyDown={handleListboxKeyboardNavigation}
|
||||
onClose={showSearchField ? () => setInputValue('') : undefined}
|
||||
isLoading={isLoading}
|
||||
searchField={
|
||||
showSearchField && (
|
||||
<TextField
|
||||
size={props.size === 'xs' || props.size === 'sm' ? 'xs' : 'sm'}
|
||||
placeholder={searchPlaceholder}
|
||||
startAdornment={<SearchIcon />}
|
||||
className="flex-shrink-0 px-8 pb-8 pt-4"
|
||||
autoFocus
|
||||
aria-expanded={isOpen ? 'true' : 'false'}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={isOpen ? listboxId : undefined}
|
||||
aria-autocomplete="list"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
onKeyDown={e => {
|
||||
handleListboxSearchFieldKeydown(e);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Field
|
||||
fieldClassNames={fieldClassNames}
|
||||
{...fieldProps}
|
||||
endAdornment={
|
||||
!hideCaret && <ComboboxEndAdornment isLoading={isLoading} />
|
||||
}
|
||||
>
|
||||
<button
|
||||
{...inputProps}
|
||||
type="button"
|
||||
data-selected-value={selectedOption?.value}
|
||||
aria-expanded={isOpen ? 'true' : 'false'}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={isOpen ? listboxId : undefined}
|
||||
ref={reference}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
onKeyDownCapture={
|
||||
!showSearchField ? handleTriggerTypeSelect : undefined
|
||||
}
|
||||
disabled={inputFieldProps.disabled}
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
className={clsx(
|
||||
fieldClassNames.input,
|
||||
!fieldProps.unstyled && minWidth,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
</Field>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
|
||||
const SelectForwardRef = React.forwardRef(Select) as <T extends object>(
|
||||
props: SelectProps<T> & {ref?: Ref<HTMLButtonElement>},
|
||||
) => ReactElement;
|
||||
export {SelectForwardRef as Select};
|
||||
|
||||
export type FormSelectProps<T extends object> = SelectProps<T> & {
|
||||
name: string;
|
||||
};
|
||||
export function FormSelect<T extends object>({
|
||||
children,
|
||||
...props
|
||||
}: FormSelectProps<T>) {
|
||||
const {
|
||||
field: {onChange, onBlur, value = null, ref},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
});
|
||||
|
||||
const formProps: Partial<SelectProps<T>> = {
|
||||
onSelectionChange: onChange,
|
||||
onBlur,
|
||||
selectedValue: value,
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
name: props.name,
|
||||
};
|
||||
|
||||
// make sure error message is not overridden by undefined or null
|
||||
const errorMessage = props.errorMessage || error?.message;
|
||||
return (
|
||||
<SelectForwardRef
|
||||
ref={ref}
|
||||
{...mergeProps(formProps, props, {errorMessage})}
|
||||
>
|
||||
{children}
|
||||
</SelectForwardRef>
|
||||
);
|
||||
}
|
||||
|
||||
export {Item as Option};
|
||||
export {Section as OptionGroup};
|
||||
191
common/resources/client/ui/forms/slider/base-slider.tsx
Executable file
191
common/resources/client/ui/forms/slider/base-slider.tsx
Executable file
@@ -0,0 +1,191 @@
|
||||
import React, {ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {getInputFieldClassNames} from '../input-field/get-input-field-class-names';
|
||||
import {UseSliderProps, UseSliderReturn} from './use-slider';
|
||||
|
||||
export interface BaseSliderProps extends UseSliderProps {
|
||||
slider: UseSliderReturn;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function BaseSlider(props: BaseSliderProps) {
|
||||
const {
|
||||
size = 'md',
|
||||
inline,
|
||||
label,
|
||||
showValueLabel = !!label,
|
||||
className,
|
||||
width = 'w-full',
|
||||
slider,
|
||||
children,
|
||||
trackColor = 'primary',
|
||||
fillColor = 'primary',
|
||||
} = props;
|
||||
|
||||
const {
|
||||
domProps,
|
||||
trackRef,
|
||||
getThumbPercent,
|
||||
getThumbValueLabel,
|
||||
labelId,
|
||||
groupId,
|
||||
thumbIds,
|
||||
isDisabled,
|
||||
numberFormatter,
|
||||
minValue,
|
||||
maxValue,
|
||||
step,
|
||||
values,
|
||||
getValueLabel,
|
||||
} = slider;
|
||||
|
||||
let outputValue = '';
|
||||
let maxLabelLength = Math.max(
|
||||
[...numberFormatter.format(minValue)].length,
|
||||
[...numberFormatter.format(maxValue)].length,
|
||||
[...numberFormatter.format(step)].length,
|
||||
);
|
||||
|
||||
if (getValueLabel) {
|
||||
outputValue = getValueLabel(values[0]);
|
||||
} else if (values.length === 1) {
|
||||
outputValue = getThumbValueLabel(0);
|
||||
} else if (values.length === 2) {
|
||||
// This should really use the NumberFormat#formatRange proposal...
|
||||
// https://github.com/tc39/ecma402/issues/393
|
||||
// https://github.com/tc39/proposal-intl-numberformat-v3#formatrange-ecma-402-393
|
||||
outputValue = `${getThumbValueLabel(0)} – ${getThumbValueLabel(1)}`;
|
||||
maxLabelLength =
|
||||
3 +
|
||||
2 *
|
||||
Math.max(
|
||||
maxLabelLength,
|
||||
[...numberFormatter.format(minValue)].length,
|
||||
[...numberFormatter.format(maxValue)].length,
|
||||
);
|
||||
}
|
||||
const style = getInputFieldClassNames({
|
||||
size,
|
||||
disabled: isDisabled,
|
||||
labelDisplay: 'flex',
|
||||
});
|
||||
|
||||
const wrapperClassname = clsx('touch-none', className, width, {
|
||||
'flex items-center': inline,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={wrapperClassname} role="group" id={groupId}>
|
||||
{(label || showValueLabel) && (
|
||||
<div className={clsx(style.label, 'select-none')}>
|
||||
{label && (
|
||||
<label
|
||||
onClick={() => {
|
||||
// Safari does not focus <input type="range"> elements when clicking on an associated <label>,
|
||||
// so do it manually. In addition, make sure we show the focus ring.
|
||||
document.getElementById(thumbIds[0])?.focus();
|
||||
}}
|
||||
id={labelId}
|
||||
htmlFor={groupId}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{showValueLabel && (
|
||||
<output
|
||||
htmlFor={thumbIds[0]}
|
||||
className="ml-auto text-right"
|
||||
aria-live="off"
|
||||
style={
|
||||
!maxLabelLength
|
||||
? undefined
|
||||
: {
|
||||
width: `${maxLabelLength}ch`,
|
||||
minWidth: `${maxLabelLength}ch`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{outputValue}
|
||||
</output>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={clsx('relative', getWrapperHeight(props))}
|
||||
{...domProps}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute inset-0 m-auto rounded',
|
||||
getTrackColor(trackColor, isDisabled),
|
||||
getTrackHeight(size),
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute inset-0 my-auto rounded',
|
||||
getFillColor(fillColor, isDisabled),
|
||||
getTrackHeight(size),
|
||||
)}
|
||||
style={{width: `${Math.max(getThumbPercent(0) * 100, 0)}%`}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getWrapperHeight({size, wrapperHeight}: UseSliderProps): string {
|
||||
if (wrapperHeight) return wrapperHeight;
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'h-14';
|
||||
case 'sm':
|
||||
return 'h-20';
|
||||
default:
|
||||
return 'h-30';
|
||||
}
|
||||
}
|
||||
|
||||
function getTrackHeight(size: UseSliderProps['size']): string {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'h-2';
|
||||
case 'sm':
|
||||
return 'h-3';
|
||||
default:
|
||||
return 'h-4';
|
||||
}
|
||||
}
|
||||
|
||||
function getTrackColor(color: string, isDisabled: boolean): string {
|
||||
if (isDisabled) {
|
||||
color = 'disabled';
|
||||
}
|
||||
switch (color) {
|
||||
case 'disabled':
|
||||
return 'bg-slider-disabled/60';
|
||||
case 'primary':
|
||||
return 'bg-primary-light';
|
||||
case 'neutral':
|
||||
return 'bg-divider';
|
||||
default:
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
function getFillColor(color: string, isDisabled: boolean): string {
|
||||
if (isDisabled) {
|
||||
color = 'disabled';
|
||||
}
|
||||
switch (color) {
|
||||
case 'disabled':
|
||||
return 'bg-slider-disabled';
|
||||
case 'primary':
|
||||
return 'bg-primary';
|
||||
default:
|
||||
return color;
|
||||
}
|
||||
}
|
||||
45
common/resources/client/ui/forms/slider/range-slider.tsx
Executable file
45
common/resources/client/ui/forms/slider/range-slider.tsx
Executable file
@@ -0,0 +1,45 @@
|
||||
import {BaseSlider} from './base-slider';
|
||||
import {useSlider, UseSliderProps} from './use-slider';
|
||||
import {SliderThumb} from './slider-thumb';
|
||||
import {useTrans} from '../../../i18n/use-trans';
|
||||
import {message} from '../../../i18n/message';
|
||||
|
||||
interface RangeSliderProps
|
||||
extends UseSliderProps<{start: number; end: number}> {}
|
||||
export function RangeSlider(props: RangeSliderProps) {
|
||||
const {onChange, onChangeEnd, value, defaultValue, ...otherProps} = props;
|
||||
const {trans} = useTrans();
|
||||
|
||||
const baseProps: UseSliderProps = {
|
||||
...otherProps,
|
||||
value: value != null ? [value.start, value.end] : undefined,
|
||||
defaultValue:
|
||||
defaultValue != null
|
||||
? [defaultValue.start, defaultValue.end]
|
||||
: // make sure that useSliderState knows we have two handles
|
||||
[props.minValue ?? 0, props.maxValue ?? 100],
|
||||
onChange(v) {
|
||||
onChange?.({start: v[0], end: v[1]});
|
||||
},
|
||||
onChangeEnd(v) {
|
||||
onChangeEnd?.({start: v[0], end: v[1]});
|
||||
},
|
||||
};
|
||||
|
||||
const slider = useSlider(baseProps);
|
||||
|
||||
return (
|
||||
<BaseSlider {...baseProps} slider={slider}>
|
||||
<SliderThumb
|
||||
ariaLabel={trans(message('minimum'))}
|
||||
index={0}
|
||||
slider={slider}
|
||||
/>
|
||||
<SliderThumb
|
||||
ariaLabel={trans(message('maximum'))}
|
||||
index={1}
|
||||
slider={slider}
|
||||
/>
|
||||
</BaseSlider>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user