157
common/resources/client/ui/list/list-item-base.tsx
Executable file
157
common/resources/client/ui/list/list-item-base.tsx
Executable file
@@ -0,0 +1,157 @@
|
||||
import clsx from 'clsx';
|
||||
import React, {
|
||||
ComponentPropsWithRef,
|
||||
JSXElementConstructor,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import {CheckIcon} from '../../icons/material/Check';
|
||||
import {To} from 'react-router-dom';
|
||||
|
||||
export interface ListItemBaseProps extends ComponentPropsWithRef<'div'> {
|
||||
startIcon?: ReactNode;
|
||||
endIcon?: ReactNode;
|
||||
endSection?: ReactNode;
|
||||
description?: ReactNode;
|
||||
textLabel?: string;
|
||||
capitalizeFirst?: boolean;
|
||||
isSelected?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isActive?: boolean;
|
||||
className?: string;
|
||||
showCheckmark?: boolean;
|
||||
elementType?: 'a' | JSXElementConstructor<any> | 'div';
|
||||
target?: string;
|
||||
to?: To;
|
||||
href?: string;
|
||||
radius?: string;
|
||||
padding?: string;
|
||||
}
|
||||
|
||||
export const ListItemBase = React.forwardRef<HTMLDivElement, ListItemBaseProps>(
|
||||
(props, ref) => {
|
||||
let {
|
||||
startIcon,
|
||||
capitalizeFirst,
|
||||
children,
|
||||
description,
|
||||
endIcon,
|
||||
endSection,
|
||||
isDisabled,
|
||||
isActive,
|
||||
isSelected,
|
||||
showCheckmark,
|
||||
elementType = 'div',
|
||||
radius,
|
||||
padding,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
if (!startIcon && showCheckmark) {
|
||||
startIcon = (
|
||||
<CheckIcon
|
||||
size="sm"
|
||||
className={clsx('text-primary', !isSelected && 'invisible')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// if (!endIcon && !endSection && showCheckmark) {
|
||||
// endIcon = (
|
||||
// <CheckIcon size="sm" className={clsx('text-primary', 'invisible')} />
|
||||
// );
|
||||
// }
|
||||
|
||||
const iconClassName = clsx(
|
||||
'icon-sm rounded overflow-hidden flex-shrink-0',
|
||||
!isDisabled && 'text-muted',
|
||||
);
|
||||
const endSectionClassName = clsx(!isDisabled && 'text-muted');
|
||||
|
||||
const Element = elementType;
|
||||
|
||||
return (
|
||||
<Element
|
||||
{...domProps}
|
||||
aria-disabled={isDisabled}
|
||||
className={itemClassName(props)}
|
||||
ref={ref}
|
||||
>
|
||||
{startIcon && <div className={iconClassName}>{startIcon}</div>}
|
||||
<div
|
||||
className={clsx(
|
||||
'min-w-auto mr-auto w-full overflow-hidden overflow-ellipsis',
|
||||
capitalizeFirst && 'first-letter:capitalize',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{description && (
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-4 whitespace-normal text-xs',
|
||||
isDisabled ? 'text-disabled' : 'text-muted',
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(endIcon || endSection) && (
|
||||
<div className={endIcon ? iconClassName : endSectionClassName}>
|
||||
{endIcon || endSection}
|
||||
</div>
|
||||
)}
|
||||
</Element>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function itemClassName({
|
||||
className,
|
||||
isSelected,
|
||||
isActive,
|
||||
isDisabled,
|
||||
showCheckmark,
|
||||
endIcon,
|
||||
endSection,
|
||||
radius,
|
||||
padding: userPadding,
|
||||
}: ListItemBaseProps): string {
|
||||
let state: string = '';
|
||||
if (isDisabled) {
|
||||
state = 'text-disabled pointer-events-none';
|
||||
} else if (isSelected) {
|
||||
if (isActive) {
|
||||
state = 'bg-primary/focus';
|
||||
} else {
|
||||
state = 'bg-primary/selected hover:bg-primary/focus';
|
||||
}
|
||||
} else if (isActive) {
|
||||
state = 'hover:bg-fg-base/15 bg-focus';
|
||||
} else {
|
||||
state = 'hover:bg-hover';
|
||||
}
|
||||
|
||||
let padding;
|
||||
|
||||
if (userPadding) {
|
||||
padding = userPadding;
|
||||
} else if (showCheckmark) {
|
||||
if (endIcon || endSection) {
|
||||
padding = 'pl-8 pr-8 py-8';
|
||||
} else {
|
||||
padding = 'pl-8 pr-24 py-8';
|
||||
}
|
||||
} else {
|
||||
padding = 'px-20 py-8';
|
||||
}
|
||||
|
||||
return clsx(
|
||||
'w-full select-none outline-none cursor-pointer',
|
||||
'text-sm truncate flex items-center gap-10',
|
||||
!isDisabled && 'text-main',
|
||||
padding,
|
||||
state,
|
||||
className,
|
||||
radius,
|
||||
);
|
||||
}
|
||||
104
common/resources/client/ui/list/list.tsx
Executable file
104
common/resources/client/ui/list/list.tsx
Executable file
@@ -0,0 +1,104 @@
|
||||
import React, {forwardRef, ReactElement, ReactNode, useState} from 'react';
|
||||
import {FocusScope, useFocusManager} from '@react-aria/focus';
|
||||
import {ListItemBase, ListItemBaseProps} from './list-item-base';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type Child = ReactElement<ListItemProps> | ReactElement<ListItemProps>[];
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
padding?: string;
|
||||
children: ReactNode;
|
||||
dataTestId?: string;
|
||||
}
|
||||
|
||||
export function List({children, className, padding, dataTestId}: Props) {
|
||||
return (
|
||||
<FocusScope>
|
||||
<ul
|
||||
data-testid={dataTestId}
|
||||
className={clsx(
|
||||
'text-base outline-none sm:text-sm',
|
||||
className,
|
||||
padding ?? 'py-4',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
</FocusScope>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListItemProps extends ListItemBaseProps {
|
||||
children: ReactNode;
|
||||
onSelected?: () => void;
|
||||
borderRadius?: string;
|
||||
}
|
||||
export const ListItem = forwardRef<HTMLDivElement, ListItemProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
onSelected,
|
||||
borderRadius = 'rounded',
|
||||
className,
|
||||
...listItemProps
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const focusManager = useFocusManager();
|
||||
const isSelectable = !!onSelected;
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
focusManager?.focusNext();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
focusManager?.focusPrevious();
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
focusManager?.focusFirst();
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
focusManager?.focusLast();
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Space':
|
||||
e.preventDefault();
|
||||
onSelected?.();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li>
|
||||
<ListItemBase
|
||||
className={clsx(className, borderRadius)}
|
||||
isActive={isActive}
|
||||
isDisabled={listItemProps.isDisabled}
|
||||
{...listItemProps}
|
||||
onFocus={e => {
|
||||
setIsActive((e.target as HTMLElement).matches(':focus-visible'));
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsActive(false);
|
||||
}}
|
||||
onClick={() => {
|
||||
onSelected?.();
|
||||
}}
|
||||
ref={ref}
|
||||
role={isSelectable ? 'button' : undefined}
|
||||
onKeyDown={isSelectable ? onKeyDown : undefined}
|
||||
tabIndex={isSelectable && !listItemProps.isDisabled ? 0 : undefined}
|
||||
>
|
||||
{children}
|
||||
</ListItemBase>
|
||||
</li>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user