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>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user