Refactor buttons (#1257)

* Refactor buttons

* Complete components creation

* Complete refactoring

* fix lint

* Complete button work
This commit is contained in:
Charles Bochet
2023-08-26 23:59:45 +02:00
committed by GitHub
parent 5d50bbd6a3
commit 1b187350c0
57 changed files with 2209 additions and 859 deletions

View File

@ -3,79 +3,245 @@ import styled from '@emotion/styled';
import { TablerIconsProps } from '@tabler/icons-react';
import { SoonPill } from '@/ui/pill/components/SoonPill';
import { rgba } from '@/ui/theme/constants/colors';
export enum ButtonSize {
Medium = 'medium',
Small = 'small',
}
export enum ButtonPosition {
Left = 'left',
Middle = 'middle',
Right = 'right',
}
export enum ButtonVariant {
Primary = 'primary',
Secondary = 'secondary',
Tertiary = 'tertiary',
TertiaryBold = 'tertiaryBold',
TertiaryLight = 'tertiaryLight',
Danger = 'danger',
}
export type ButtonSize = 'medium' | 'small';
export type ButtonPosition = 'standalone' | 'left' | 'middle' | 'right';
export type ButtonVariant = 'primary' | 'secondary' | 'tertiary';
export type ButtonAccent = 'default' | 'blue' | 'danger';
export type ButtonProps = {
className?: string;
icon?: React.ReactNode;
title?: string;
fullWidth?: boolean;
variant?: ButtonVariant;
size?: ButtonSize;
position?: ButtonPosition;
accent?: ButtonAccent;
soon?: boolean;
disabled?: boolean;
} & React.ComponentProps<'button'>;
focus?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
};
const StyledButton = styled.button<
Pick<ButtonProps, 'fullWidth' | 'variant' | 'size' | 'position' | 'title'>
Pick<
ButtonProps,
'fullWidth' | 'variant' | 'size' | 'position' | 'accent' | 'focus'
>
>`
align-items: center;
background: ${({ theme, variant, disabled }) => {
${({ theme, variant, accent, disabled, focus }) => {
switch (variant) {
case 'primary':
if (disabled) {
return rgba(theme.color.blue, 0.4);
} else {
return theme.color.blue;
switch (accent) {
case 'default':
return `
background: ${theme.background.secondary};
border-color: ${
!disabled
? focus
? theme.color.blue
: theme.background.transparent.light
: 'transparent'
};
color: ${
!disabled
? theme.font.color.secondary
: theme.font.color.extraLight
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
&:hover {
background: ${
!disabled
? theme.background.tertiary
: theme.background.secondary
};
}
&:active {
background: ${
!disabled
? theme.background.quaternary
: theme.background.secondary
};
}
`;
case 'blue':
return `
background: ${!disabled ? theme.color.blue : theme.color.blue20};
border-color: ${
!disabled
? focus
? theme.color.blue
: theme.background.transparent.light
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
color: ${theme.grayScale.gray0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
&:hover {
background: ${
!disabled ? theme.color.blue50 : theme.color.blue20
};
}
&:active {
background: ${
!disabled ? theme.color.blue60 : theme.color.blue20
};
}
`;
case 'danger':
return `
background: ${!disabled ? theme.color.red : theme.color.red20};
border-color: ${
!disabled
? focus
? theme.color.red
: theme.background.transparent.light
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus ? `0 0 0 3px ${theme.color.red10}` : 'none'
};
color: ${theme.grayScale.gray0};
&:hover {
background: ${
!disabled ? theme.color.red50 : theme.color.red20
};
}
&:active {
background: ${
!disabled ? theme.color.red50 : theme.color.red20
};
}
`;
}
break;
case 'secondary':
return theme.background.primary;
default:
return 'transparent';
}
}};
border-color: ${({ theme, variant }) => {
switch (variant) {
case 'primary':
case 'secondary':
return `${theme.background.transparent.medium}`;
case 'danger':
return `${theme.border.color.danger}`;
case 'tertiary':
default:
return 'none';
switch (accent) {
case 'default':
return `
background: ${
focus ? theme.background.transparent.primary : 'transparent'
};
border-color: ${
variant === 'secondary'
? !disabled && focus
? theme.color.blue
: theme.background.transparent.light
: focus
? theme.color.blue
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
color: ${
!disabled
? theme.font.color.secondary
: theme.font.color.extraLight
};
&:hover {
background: ${
!disabled ? theme.background.transparent.light : 'transparent'
};
}
&:active {
background: ${
!disabled ? theme.background.transparent.light : 'transparent'
};
}
`;
case 'blue':
return `
background: ${
focus ? theme.background.transparent.primary : 'transparent'
};
border-color: ${
variant === 'secondary'
? focus
? theme.color.blue
: theme.color.blue20
: focus
? theme.color.blue
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
color: ${!disabled ? theme.color.blue : theme.accent.accent4060};
&:hover {
background: ${
!disabled ? theme.accent.tertiary : 'transparent'
};
}
&:active {
background: ${
!disabled ? theme.accent.secondary : 'transparent'
};
}
`;
case 'danger':
return `
background: ${
!disabled ? theme.background.transparent.primary : 'transparent'
};
border-color: ${
variant === 'secondary'
? focus
? theme.color.red
: theme.color.red20
: focus
? theme.color.red
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus ? `0 0 0 3px ${theme.color.red10}` : 'none'
};
color: ${!disabled ? theme.font.color.danger : theme.color.red20};
&:hover {
background: ${
!disabled ? theme.background.danger : 'transparent'
};
}
&:active {
background: ${
!disabled ? theme.background.danger : 'transparent'
};
}
`;
}
}
}};
border-radius: ${({ position }) => {
}}
border-radius: ${({ position, theme }) => {
switch (position) {
case 'left':
return '4px 0px 0px 4px';
return `${theme.border.radius.sm} 0px 0px ${theme.border.radius.sm}`;
case 'right':
return '0px 4px 4px 0px';
return `0px ${theme.border.radius.sm} ${theme.border.radius.sm} 0px`;
case 'middle':
return '0px';
default:
return '4px';
case 'standalone':
return theme.border.radius.sm;
}
}};
border-style: solid;
@ -83,68 +249,20 @@ const StyledButton = styled.button<
switch (variant) {
case 'primary':
case 'secondary':
case 'danger':
return position === 'middle' ? `1px 0 1px 0` : `1px`;
return position === 'middle' ? '1px 0px' : '1px';
case 'tertiary':
default:
return '0';
}
}};
box-shadow: ${({ theme, variant }) => {
switch (variant) {
case 'primary':
case 'secondary':
return theme.boxShadow.extraLight;
default:
return 'none';
}
}};
color: ${({ theme, variant, disabled }) => {
if (disabled) {
switch (variant) {
case 'primary':
return theme.grayScale.gray0;
case 'danger':
return theme.border.color.danger;
default:
return theme.font.color.extraLight;
}
}
switch (variant) {
case 'primary':
return theme.grayScale.gray0;
case 'tertiaryLight':
return theme.font.color.tertiary;
case 'danger':
return theme.color.red;
default:
return theme.font.color.secondary;
}
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme, variant }) => {
switch (variant) {
case 'tertiary':
case 'tertiaryLight':
return theme.font.weight.regular;
default:
return theme.font.weight.medium;
}
}};
font-weight: 500;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ size }) => (size === 'small' ? '24px' : '32px')};
justify-content: flex-start;
padding: ${({ theme, title }) => {
if (!title) {
return `${theme.spacing(1)}`;
}
return `${theme.spacing(2)} ${theme.spacing(3)}`;
padding: ${({ theme }) => {
return `0 ${theme.spacing(2)}`;
}};
transition: background 0.1s ease;
@ -153,49 +271,24 @@ const StyledButton = styled.button<
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
&:hover,
&:active {
${({ theme, variant, disabled }) => {
if (disabled) {
return '';
}
switch (variant) {
case 'primary':
return `background: linear-gradient(0deg, ${theme.background.transparent.medium} 0%, ${theme.background.transparent.medium} 100%), ${theme.color.blue}`;
case 'danger':
return `background: ${theme.background.transparent.danger}`;
default:
return `background: ${theme.background.tertiary}`;
}
}};
}
&:focus {
outline: none;
${({ theme, variant }) => {
switch (variant) {
case 'tertiaryLight':
case 'tertiaryBold':
case 'tertiary':
return `color: ${theme.color.blue};`;
default:
return '';
}
}};
}
`;
export function Button({
className,
icon: initialIcon,
title,
fullWidth = false,
variant = ButtonVariant.Primary,
size = ButtonSize.Medium,
position,
variant = 'primary',
size = 'medium',
accent = 'default',
position = 'standalone',
soon = false,
disabled = false,
...props
focus = false,
onClick,
}: ButtonProps) {
const icon = useMemo(() => {
if (!initialIcon || !React.isValidElement(initialIcon)) {
@ -214,8 +307,10 @@ export function Button({
size={size}
position={position}
disabled={soon || disabled}
title={title}
{...props}
focus={focus}
accent={accent}
className={className}
onClick={onClick}
>
{icon}
{title}

View File

@ -8,11 +8,19 @@ const StyledButtonGroupContainer = styled.div`
display: flex;
`;
type ButtonGroupProps = Pick<ButtonProps, 'variant' | 'size'> & {
export type ButtonGroupProps = Pick<
ButtonProps,
'variant' | 'size' | 'accent'
> & {
children: ReactNode[];
};
export function ButtonGroup({ children, variant, size }: ButtonGroupProps) {
export function ButtonGroup({
children,
variant,
size,
accent,
}: ButtonGroupProps) {
return (
<StyledButtonGroupContainer>
{React.Children.map(children, (child, index) => {
@ -21,19 +29,23 @@ export function ButtonGroup({ children, variant, size }: ButtonGroupProps) {
let position: ButtonPosition;
if (index === 0) {
position = ButtonPosition.Left;
position = 'left';
} else if (index === children.length - 1) {
position = ButtonPosition.Right;
position = 'right';
} else {
position = ButtonPosition.Middle;
position = 'middle';
}
const additionalProps: any = { position };
const additionalProps: any = { position, variant, accent, size };
if (variant) {
additionalProps.variant = variant;
}
if (accent) {
additionalProps.variant = variant;
}
if (size) {
additionalProps.size = size;
}

View File

@ -1,144 +0,0 @@
import React, { useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { IconChevronDown } from '@/ui/icon/index';
type ButtonProps = React.ComponentProps<'button'>;
export type DropdownOptionType = {
key: string;
label: string;
icon: React.ReactNode;
};
type OwnProps = {
options: DropdownOptionType[];
selectedOptionKey?: string;
onSelection: (value: DropdownOptionType) => void;
} & ButtonProps;
const StyledButton = styled.button<ButtonProps & { isOpen: boolean }>`
align-items: center;
background: ${({ theme }) => theme.background.tertiary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-bottom-left-radius: ${({ isOpen, theme }) =>
isOpen ? 0 : theme.border.radius.sm};
border-bottom-right-radius: ${({ isOpen, theme }) =>
isOpen ? 0 : theme.border.radius.sm};
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.secondary};
cursor: pointer;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
svg {
align-items: center;
display: flex;
height: 14px;
justify-content: center;
width: 14px;
}
`;
const StyledDropdownItem = styled.button<ButtonProps>`
align-items: center;
background: ${({ theme }) => theme.background.tertiary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
color: ${({ theme }) => theme.font.color.secondary};
cursor: pointer;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
svg {
align-items: center;
display: flex;
height: 14px;
justify-content: center;
width: 14px;
}
`;
const StyledDropdownContainer = styled.div`
position: relative;
`;
const StyledDropdownMenu = styled.div`
display: flex;
flex-direction: column;
position: absolute;
width: 100%;
`;
export function DropdownButton_Deprecated({
options,
selectedOptionKey,
onSelection,
...buttonProps
}: OwnProps) {
const [isOpen, setIsOpen] = useState(false);
const [selectedOption, setSelectedOption] = useState<
DropdownOptionType | undefined
>(undefined);
useEffect(() => {
if (selectedOptionKey) {
const option = options.find((option) => option.key === selectedOptionKey);
setSelectedOption(option);
} else {
setSelectedOption(options[0]);
}
}, [selectedOptionKey, options]);
if (!options.length) {
throw new Error('You must provide at least one option.');
}
const handleSelect =
(option: DropdownOptionType) =>
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
onSelection(option);
setSelectedOption(option);
setIsOpen(false);
};
return (
<>
{selectedOption && (
<StyledDropdownContainer>
<StyledButton
onClick={() => setIsOpen(!isOpen)}
{...buttonProps}
isOpen={isOpen}
>
{selectedOption.icon}
{selectedOption.label}
{options.length > 1 && <IconChevronDown />}
</StyledButton>
{isOpen && (
<StyledDropdownMenu>
{options
.filter((option) => option.label !== selectedOption.label)
.map((option, index) => (
<StyledDropdownItem
key={index}
onClick={handleSelect(option)}
>
{option.icon}
{option.label}
</StyledDropdownItem>
))}
</StyledDropdownMenu>
)}
</StyledDropdownContainer>
)}
</>
);
}

View File

@ -0,0 +1,113 @@
import React, { useMemo } from 'react';
import styled from '@emotion/styled';
import { TablerIconsProps } from '@tabler/icons-react';
export type FloatingButtonSize = 'small' | 'medium';
export type FloatingButtonPosition = 'standalone' | 'left' | 'middle' | 'right';
export type FloatingButtonProps = {
className?: string;
icon?: React.ReactNode;
title?: string;
size?: FloatingButtonSize;
position?: FloatingButtonPosition;
applyShadow?: boolean;
applyBlur?: boolean;
disabled?: boolean;
focus?: boolean;
};
const StyledButton = styled.button<
Pick<
FloatingButtonProps,
'size' | 'focus' | 'position' | 'applyBlur' | 'applyShadow'
>
>`
align-items: center;
backdrop-filter: ${({ applyBlur }) => (applyBlur ? 'blur(20px)' : 'none')};
background: ${({ theme }) => theme.background.primary};
border: ${({ focus, theme }) =>
focus ? `1px solid ${theme.color.blue}` : 'none'};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ theme, applyShadow, focus }) =>
applyShadow
? `0px 2px 4px 0px ${
theme.background.transparent.light
}, 0px 0px 4px 0px ${theme.background.transparent.medium}${
focus ? `,0 0 0 3px ${theme.color.blue10}` : ''
}`
: focus
? `0 0 0 3px ${theme.color.blue10}`
: 'none'};
color: ${({ theme, disabled, focus }) => {
return !disabled
? focus
? theme.color.blue
: theme.font.color.secondary
: theme.font.color.extraLight;
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ size }) => (size === 'small' ? '24px' : '32px')};
padding: ${({ theme }) => {
return `0 ${theme.spacing(2)}`;
}};
transition: background 0.1s ease;
white-space: nowrap;
&:hover {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.lighter : 'transparent'};
}
&:active {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.medium : 'transparent'};
}
&:focus {
outline: none;
}
`;
export function FloatingButton({
className,
icon: initialIcon,
title,
size = 'small',
applyBlur = true,
applyShadow = true,
disabled = false,
focus = false,
}: FloatingButtonProps) {
const icon = useMemo(() => {
if (!initialIcon || !React.isValidElement(initialIcon)) {
return null;
}
return React.cloneElement<TablerIconsProps>(initialIcon as any, {
size: 14,
});
}, [initialIcon]);
return (
<StyledButton
disabled={disabled}
focus={focus && !disabled}
size={size}
applyBlur={applyBlur}
applyShadow={applyShadow}
className={className}
>
{icon}
{title}
</StyledButton>
);
}

View File

@ -0,0 +1,50 @@
import React from 'react';
import styled from '@emotion/styled';
import { FloatingButtonPosition, FloatingButtonProps } from './FloatingButton';
const StyledFloatingButtonGroupContainer = styled.div`
backdrop-filter: blur(20px);
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) =>
`0px 2px 4px 0px ${theme.background.transparent.light}, 0px 0px 4px 0px ${theme.background.transparent.medium}`};
display: flex;
`;
export type FloatingButtonGroupProps = Pick<FloatingButtonProps, 'size'> & {
children: React.ReactElement[];
};
export function FloatingButtonGroup({
children,
size,
}: FloatingButtonGroupProps) {
return (
<StyledFloatingButtonGroupContainer>
{React.Children.map(children, (child, index) => {
let position: FloatingButtonPosition;
if (index === 0) {
position = 'left';
} else if (index === children.length - 1) {
position = 'right';
} else {
position = 'middle';
}
const additionalProps: any = {
position,
size,
applyShadow: false,
applyBlur: false,
};
if (size) {
additionalProps.size = size;
}
return React.cloneElement(child, additionalProps);
})}
</StyledFloatingButtonGroupContainer>
);
}

View File

@ -0,0 +1,127 @@
import React, { useMemo } from 'react';
import styled from '@emotion/styled';
import { TablerIconsProps } from '@tabler/icons-react';
export type FloatingIconButtonSize = 'small' | 'medium';
export type FloatingIconButtonPosition =
| 'standalone'
| 'left'
| 'middle'
| 'right';
export type FloatingIconButtonProps = {
className?: string;
icon?: React.ReactNode;
size?: FloatingIconButtonSize;
position?: FloatingIconButtonPosition;
applyShadow?: boolean;
applyBlur?: boolean;
disabled?: boolean;
focus?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
};
const StyledButton = styled.button<
Pick<
FloatingIconButtonProps,
'size' | 'position' | 'applyShadow' | 'applyBlur' | 'focus'
>
>`
align-items: center;
backdrop-filter: ${({ applyBlur }) => (applyBlur ? 'blur(20px)' : 'none')};
background: ${({ theme }) => theme.background.primary};
border: ${({ focus, theme }) =>
focus ? `1px solid ${theme.color.blue}` : 'transparent'};
border-radius: ${({ position, theme }) => {
switch (position) {
case 'left':
return `${theme.border.radius.sm} 0px 0px ${theme.border.radius.sm}`;
case 'right':
return `0px ${theme.border.radius.sm} ${theme.border.radius.sm} 0px`;
case 'middle':
return '0px';
case 'standalone':
return theme.border.radius.sm;
}
}};
box-shadow: ${({ theme, applyShadow, focus }) =>
applyShadow
? `0px 2px 4px ${theme.background.transparent.light}, 0px 0px 4px ${
theme.background.transparent.medium
}${focus ? `,0 0 0 3px ${theme.color.blue10}` : ''}`
: focus
? `0 0 0 3px ${theme.color.blue10}`
: 'none'};
color: ${({ theme, disabled, focus }) => {
return !disabled
? focus
? theme.color.blue
: theme.font.color.tertiary
: theme.font.color.extraLight;
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ size }) => (size === 'small' ? '24px' : '32px')};
justify-content: center;
padding: 0;
transition: background 0.1s ease;
white-space: nowrap;
width: ${({ size }) => (size === 'small' ? '24px' : '32px')};
&:hover {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.lighter : 'transparent'};
}
&:active {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.medium : 'transparent'};
}
&:focus {
outline: none;
}
`;
export function FloatingIconButton({
className,
icon: initialIcon,
size = 'small',
position = 'standalone',
applyShadow = true,
applyBlur = true,
disabled = false,
focus = false,
onClick,
}: FloatingIconButtonProps) {
const icon = useMemo(() => {
if (!initialIcon || !React.isValidElement(initialIcon)) {
return null;
}
return React.cloneElement<TablerIconsProps>(initialIcon as any, {
size: 16,
});
}, [initialIcon]);
return (
<StyledButton
disabled={disabled}
focus={focus && !disabled}
size={size}
applyShadow={applyShadow}
applyBlur={applyBlur}
className={className}
position={position}
onClick={onClick}
>
{icon}
</StyledButton>
);
}

View File

@ -0,0 +1,56 @@
import React from 'react';
import styled from '@emotion/styled';
import {
FloatingIconButtonPosition,
FloatingIconButtonProps,
} from './FloatingIconButton';
const StyledFloatingIconButtonGroupContainer = styled.div`
backdrop-filter: blur(20px);
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) =>
`0px 2px 4px 0px ${theme.background.transparent.light}, 0px 0px 4px 0px ${theme.background.transparent.medium}`};
display: flex;
`;
export type FloatingIconButtonGroupProps = Pick<
FloatingIconButtonProps,
'size'
> & {
children: React.ReactElement[];
};
export function FloatingIconButtonGroup({
children,
size,
}: FloatingIconButtonGroupProps) {
return (
<StyledFloatingIconButtonGroupContainer>
{React.Children.map(children, (child, index) => {
let position: FloatingIconButtonPosition;
if (index === 0) {
position = 'left';
} else if (index === children.length - 1) {
position = 'right';
} else {
position = 'middle';
}
const additionalProps: any = {
position,
size,
applyShadow: false,
applyBlur: false,
};
if (size) {
additionalProps.size = size;
}
return React.cloneElement(child, additionalProps);
})}
</StyledFloatingIconButtonGroupContainer>
);
}

View File

@ -1,143 +1,307 @@
import React from 'react';
import React, { useMemo } from 'react';
import styled from '@emotion/styled';
import { TablerIconsProps } from '@tabler/icons-react';
export type IconButtonVariant = 'transparent' | 'border' | 'shadow' | 'white';
export type IconButtonSize = 'medium' | 'small';
export type IconButtonPosition = 'standalone' | 'left' | 'middle' | 'right';
export type IconButtonVariant = 'primary' | 'secondary' | 'tertiary';
export type IconButtonAccent = 'default' | 'blue' | 'danger';
export type IconButtonSize = 'large' | 'medium' | 'small';
export type IconButtonFontColor =
| 'primary'
| 'secondary'
| 'tertiary'
| 'danger';
export type ButtonProps = {
export type IconButtonProps = {
className?: string;
icon?: React.ReactNode;
variant?: IconButtonVariant;
size?: IconButtonSize;
textColor?: IconButtonFontColor;
} & React.ComponentProps<'button'>;
position?: IconButtonPosition;
accent?: IconButtonAccent;
disabled?: boolean;
focus?: boolean;
dataTestId?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
};
const StyledIconButton = styled.button<
Pick<ButtonProps, 'variant' | 'size' | 'textColor'>
const StyledButton = styled.button<
Pick<IconButtonProps, 'variant' | 'size' | 'position' | 'accent' | 'focus'>
>`
align-items: center;
background: ${({ theme, variant }) => {
${({ theme, variant, accent, disabled, focus }) => {
switch (variant) {
case 'shadow':
case 'white':
return theme.background.transparent.primary;
case 'transparent':
case 'border':
default:
return 'transparent';
case 'primary':
switch (accent) {
case 'default':
return `
background: ${theme.background.secondary};
border-color: ${
!disabled
? focus
? theme.color.blue
: theme.background.transparent.light
: 'transparent'
};
color: ${
!disabled
? theme.font.color.secondary
: theme.font.color.extraLight
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
&:hover {
background: ${
!disabled
? theme.background.tertiary
: theme.background.secondary
};
}
&:active {
background: ${
!disabled
? theme.background.quaternary
: theme.background.secondary
};
}
`;
case 'blue':
return `
background: ${!disabled ? theme.color.blue : theme.color.blue20};
border-color: ${
!disabled
? focus
? theme.color.blue
: theme.background.transparent.light
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
color: ${theme.grayScale.gray0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
&:hover {
background: ${
!disabled ? theme.color.blue50 : theme.color.blue20
};
}
&:active {
background: ${
!disabled ? theme.color.blue60 : theme.color.blue20
};
}
`;
case 'danger':
return `
background: ${!disabled ? theme.color.red : theme.color.red20};
border-color: ${
!disabled
? focus
? theme.color.red
: theme.background.transparent.light
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus ? `0 0 0 3px ${theme.color.red10}` : 'none'
};
color: ${theme.grayScale.gray0};
&:hover {
background: ${
!disabled ? theme.color.red50 : theme.color.red20
};
}
&:active {
background: ${
!disabled ? theme.color.red50 : theme.color.red20
};
}
`;
}
break;
case 'secondary':
case 'tertiary':
switch (accent) {
case 'default':
return `
background: ${
focus ? theme.background.transparent.primary : 'transparent'
};
border-color: ${
variant === 'secondary'
? !disabled && focus
? theme.color.blue
: theme.background.transparent.light
: focus
? theme.color.blue
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
color: ${
!disabled
? theme.font.color.secondary
: theme.font.color.extraLight
};
&:hover {
background: ${
!disabled ? theme.background.transparent.light : 'transparent'
};
}
&:active {
background: ${
!disabled ? theme.background.transparent.light : 'transparent'
};
}
`;
case 'blue':
return `
background: ${
focus ? theme.background.transparent.primary : 'transparent'
};
border-color: ${
variant === 'secondary'
? !disabled
? theme.color.blue
: theme.color.blue20
: focus
? theme.color.blue
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
color: ${!disabled ? theme.color.blue : theme.accent.accent4060};
&:hover {
background: ${
!disabled ? theme.accent.tertiary : 'transparent'
};
}
&:active {
background: ${
!disabled ? theme.accent.secondary : 'transparent'
};
}
`;
case 'danger':
return `
background: ${
!disabled ? theme.background.transparent.primary : 'transparent'
};
border-color: ${
variant === 'secondary'
? !disabled
? theme.color.red
: theme.color.red20
: focus
? theme.color.red
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus ? `0 0 0 3px ${theme.color.red10}` : 'none'
};
color: ${!disabled ? theme.font.color.danger : theme.color.red20};
&:hover {
background: ${
!disabled ? theme.background.danger : 'transparent'
};
}
&:active {
background: ${
!disabled ? theme.background.danger : 'transparent'
};
}
`;
}
}
}};
border-color: ${({ theme, variant }) => {
switch (variant) {
case 'border':
return theme.border.color.medium;
case 'shadow':
case 'white':
case 'transparent':
default:
return 'none';
}}
border-radius: ${({ position, theme }) => {
switch (position) {
case 'left':
return `${theme.border.radius.sm} 0px 0px ${theme.border.radius.sm}`;
case 'right':
return `0px ${theme.border.radius.sm} ${theme.border.radius.sm} 0px`;
case 'middle':
return '0px';
case 'standalone':
return theme.border.radius.sm;
}
}};
border-radius: ${({ theme }) => {
return theme.border.radius.sm;
}};
border-style: solid;
border-width: ${({ variant }) => {
border-width: ${({ variant, position }) => {
switch (variant) {
case 'border':
return '1px';
case 'shadow':
case 'white':
case 'transparent':
default:
return 0;
case 'primary':
case 'secondary':
return position === 'middle' ? '1px 0px' : '1px';
case 'tertiary':
return '0';
}
}};
box-shadow: ${({ theme, variant }) => {
switch (variant) {
case 'shadow':
return theme.boxShadow.light;
case 'border':
case 'white':
case 'transparent':
default:
return 'none';
}
}};
color: ${({ theme, disabled, textColor }) => {
if (disabled) {
return theme.font.color.extraLight;
}
return textColor === 'danger'
? theme.color.red
: theme.font.color[textColor ?? 'secondary'];
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-shrink: 0;
height: ${({ size }) => {
switch (size) {
case 'large':
return '32px';
case 'medium':
return '24px';
case 'small':
default:
return '20px';
}
}};
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: 500;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ size }) => (size === 'small' ? '24px' : '32px')};
justify-content: center;
outline: none;
padding: 0;
transition: background 0.1s ease;
&:hover {
background: ${({ theme, disabled }) => {
return disabled ? 'auto' : theme.background.transparent.light;
}};
white-space: nowrap;
width: ${({ size }) => (size === 'small' ? '24px' : '32px')};
&:focus {
outline: none;
}
user-select: none;
&:active {
background: ${({ theme, disabled }) => {
return disabled ? 'auto' : theme.background.transparent.medium;
}};
}
width: ${({ size }) => {
switch (size) {
case 'large':
return '32px';
case 'medium':
return '24px';
case 'small':
default:
return '20px';
}
}};
`;
export function IconButton({
icon,
variant = 'transparent',
className,
icon: initialIcon,
variant = 'primary',
size = 'medium',
textColor = 'tertiary',
accent = 'default',
position = 'standalone',
disabled = false,
...props
}: ButtonProps) {
focus = false,
dataTestId,
onClick,
}: IconButtonProps) {
const icon = useMemo(() => {
if (!initialIcon || !React.isValidElement(initialIcon)) {
return <></>;
}
return React.cloneElement<TablerIconsProps>(initialIcon as any, {
size: 16,
});
}, [initialIcon]);
return (
<StyledIconButton
<StyledButton
data-testid={dataTestId}
variant={variant}
size={size}
position={position}
disabled={disabled}
textColor={textColor}
{...props}
focus={focus}
accent={accent}
className={className}
onClick={onClick}
>
{icon}
</StyledIconButton>
</StyledButton>
);
}

View File

@ -1,39 +1,55 @@
import React, { type ComponentProps } from 'react';
import React from 'react';
import styled from '@emotion/styled';
import type { IconButtonSize, IconButtonVariant } from './IconButton';
import { IconButtonPosition, IconButtonProps } from './IconButton';
const StyledIconButtonGroupContainer = styled.div`
align-items: flex-start;
background: ${({ theme }) => theme.background.transparent.primary};
border-radius: ${({ theme }) => theme.spacing(1)};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
gap: ${({ theme }) => theme.spacing(0.5)};
padding: ${({ theme }) => theme.spacing(0.5)};
`;
export type IconButtonGroupProps = Omit<ComponentProps<'div'>, 'children'> & {
variant: IconButtonVariant;
size: IconButtonSize;
children: React.ReactElement | React.ReactElement[];
export type IconButtonGroupProps = Pick<
IconButtonProps,
'variant' | 'size' | 'accent'
> & {
children: React.ReactElement[];
};
export function IconButtonGroup({
children,
variant,
size,
...props
accent,
}: IconButtonGroupProps) {
return (
<StyledIconButtonGroupContainer {...props}>
{React.Children.map(
Array.isArray(children) ? children : [children],
(child) =>
React.cloneElement(child, {
...(variant ? { variant } : {}),
...(size ? { size } : {}),
}),
)}
<StyledIconButtonGroupContainer>
{React.Children.map(children, (child, index) => {
let position: IconButtonPosition;
if (index === 0) {
position = 'left';
} else if (index === children.length - 1) {
position = 'right';
} else {
position = 'middle';
}
const additionalProps: any = { position };
if (variant) {
additionalProps.variant = variant;
}
if (accent) {
additionalProps.accent = accent;
}
if (size) {
additionalProps.size = size;
}
return React.cloneElement(child, additionalProps);
})}
</StyledIconButtonGroupContainer>
);
}

View File

@ -0,0 +1,109 @@
import React, { MouseEvent, useMemo } from 'react';
import styled from '@emotion/styled';
import { TablerIconsProps } from '@tabler/icons-react';
export type LightButtonAccent = 'secondary' | 'tertiary';
export type LightButtonProps = {
className?: string;
icon?: React.ReactNode;
title?: string;
accent?: LightButtonAccent;
active?: boolean;
disabled?: boolean;
focus?: boolean;
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
};
const StyledButton = styled.button<
Pick<LightButtonProps, 'accent' | 'active' | 'focus'>
>`
align-items: center;
background: transparent;
border: ${({ theme, focus }) =>
focus ? `1px solid ${theme.color.blue}` : 'none'};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ theme, focus }) =>
focus ? `0 0 0 3px ${theme.color.blue10}` : 'none'};
color: ${({ theme, accent, active, disabled, focus }) => {
switch (accent) {
case 'secondary':
return active || focus
? theme.color.blue
: !disabled
? theme.font.color.secondary
: theme.font.color.extraLight;
case 'tertiary':
return active || focus
? theme.color.blue
: !disabled
? theme.font.color.tertiary
: theme.font.color.extraLight;
}
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
gap: ${({ theme }) => theme.spacing(1)};
height: 24px;
padding: ${({ theme }) => {
return `0 ${theme.spacing(2)}`;
}};
transition: background 0.1s ease;
white-space: nowrap;
&:hover {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.light : 'transparent'};
}
&:focus {
outline: none;
}
&:active {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.medium : 'transparent'};
}
`;
export function LightButton({
className,
icon: initialIcon,
title,
active = false,
accent = 'secondary',
disabled = false,
focus = false,
onClick,
}: LightButtonProps) {
const icon = useMemo(() => {
if (!initialIcon || !React.isValidElement(initialIcon)) {
return null;
}
return React.cloneElement<TablerIconsProps>(initialIcon as any, {
size: 14,
});
}, [initialIcon]);
return (
<StyledButton
onClick={onClick}
disabled={disabled}
focus={focus && !disabled}
accent={accent}
className={className}
active={active}
>
{icon}
{title}
</StyledButton>
);
}

View File

@ -0,0 +1,112 @@
import React, { MouseEvent, useMemo } from 'react';
import styled from '@emotion/styled';
import { TablerIconsProps } from '@tabler/icons-react';
export type LightIconButtonAccent = 'secondary' | 'tertiary';
export type LightIconButtonSize = 'small' | 'medium';
export type LightIconButtonProps = {
className?: string;
icon?: React.ReactNode;
title?: string;
size?: LightIconButtonSize;
accent?: LightIconButtonAccent;
active?: boolean;
disabled?: boolean;
focus?: boolean;
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
};
const StyledButton = styled.button<
Pick<LightIconButtonProps, 'accent' | 'active' | 'size' | 'focus'>
>`
align-items: center;
background: transparent;
border: none;
border: ${({ disabled, theme, focus }) =>
!disabled && focus ? `1px solid ${theme.color.blue}` : 'none'};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ disabled, theme, focus }) =>
!disabled && focus ? `0 0 0 3px ${theme.color.blue10}` : 'none'};
color: ${({ theme, accent, active, disabled, focus }) => {
switch (accent) {
case 'secondary':
return active || focus
? theme.color.blue
: !disabled
? theme.font.color.secondary
: theme.font.color.extraLight;
case 'tertiary':
return active || focus
? theme.color.blue
: !disabled
? theme.font.color.tertiary
: theme.font.color.extraLight;
}
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ size }) => (size === 'small' ? '24px' : '32px')};
justify-content: center;
padding: 0;
transition: background 0.1s ease;
white-space: nowrap;
width: ${({ size }) => (size === 'small' ? '24px' : '32px')};
&:hover {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.light : 'transparent'};
}
&:focus {
outline: none;
}
&:active {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.medium : 'transparent'};
}
`;
export function LightIconButton({
className,
icon: initialIcon,
active = false,
size = 'small',
accent = 'secondary',
disabled = false,
focus = false,
onClick,
}: LightIconButtonProps) {
const icon = useMemo(() => {
if (!initialIcon || !React.isValidElement(initialIcon)) {
return null;
}
return React.cloneElement<TablerIconsProps>(initialIcon as any, {
size: 16,
});
}, [initialIcon]);
return (
<StyledButton
onClick={onClick}
disabled={disabled}
focus={focus && !disabled}
accent={accent}
className={className}
size={size}
active={active}
>
{icon}
</StyledButton>
);
}

View File

@ -1,89 +1,106 @@
import { expect, jest } from '@storybook/jest';
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { IconSearch } from '@/ui/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { Button, ButtonPosition, ButtonSize, ButtonVariant } from '../Button';
import {
Button,
ButtonAccent,
ButtonPosition,
ButtonSize,
ButtonVariant,
} from '../Button';
const meta: Meta<typeof Button> = {
title: 'UI/Button/Button',
component: Button,
argTypes: {
icon: {
type: 'boolean',
mapping: {
true: <IconSearch size={14} />,
false: undefined,
},
},
position: {
control: 'radio',
options: [undefined, ...Object.values(ButtonPosition)],
},
},
args: { title: 'Lorem ipsum' },
};
export default meta;
type Story = StoryObj<typeof Button>;
const clickJestFn = jest.fn();
export const Default: Story = {
args: { onClick: clickJestFn },
decorators: [ComponentDecorator],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
const numberOfClicks = clickJestFn.mock.calls.length;
await userEvent.click(button);
expect(clickJestFn).toHaveBeenCalledTimes(numberOfClicks + 1);
args: {
title: 'Button',
size: 'small',
variant: 'primary',
accent: 'danger',
disabled: false,
focus: false,
fullWidth: false,
soon: false,
position: 'standalone',
icon: <IconSearch />,
className: '',
},
decorators: [ComponentDecorator],
};
export const Sizes: Story = {
export const Catalog: Story = {
args: { title: 'Filter', icon: <IconSearch /> },
argTypes: {
size: { control: false },
variant: { control: false },
accent: { control: false },
disabled: { control: false },
focus: { control: false },
fullWidth: { control: false },
soon: { control: false },
position: { control: false },
className: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: Object.values(ButtonSize),
values: ['small', 'medium'] satisfies ButtonSize[],
props: (size: ButtonSize) => ({ size }),
},
],
},
},
decorators: [CatalogDecorator],
};
export const Variants: Story = {
argTypes: {
disabled: { control: false },
variant: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.active'], focus: ['.focus'] },
catalog: {
dimensions: [
{
name: 'state',
values: ['default', 'disabled', 'hover', 'active', 'focus'],
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'focus',
'disabled+focus',
],
props: (state: string) => {
if (state === 'disabled') return { disabled: true };
if (state === 'default') return {};
return { className: state };
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'active':
return { active: true };
case 'disabled+focus':
return { focus: true, disabled: true };
default:
return {};
}
},
},
{
name: 'accents',
values: ['default', 'blue', 'danger'] satisfies ButtonAccent[],
props: (accent: ButtonAccent) => ({ accent }),
},
{
name: 'variants',
values: Object.values(ButtonVariant),
values: [
'primary',
'secondary',
'tertiary',
] satisfies ButtonVariant[],
props: (variant: ButtonVariant) => ({ variant }),
},
],
@ -92,18 +109,71 @@ export const Variants: Story = {
decorators: [CatalogDecorator],
};
export const Positions: Story = {
export const SoonCatalog: Story = {
args: { title: 'Filter', icon: <IconSearch />, soon: true },
argTypes: {
size: { control: false },
variant: { control: false },
accent: { control: false },
disabled: { control: false },
focus: { control: false },
fullWidth: { control: false },
soon: { control: false },
position: { control: false },
className: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
catalog: {
dimensions: [
{
name: 'positions',
values: ['none', ...Object.values(ButtonPosition)],
props: (position: ButtonPosition | 'none') =>
position === 'none' ? {} : { position },
name: 'sizes',
values: ['small', 'medium'] satisfies ButtonSize[],
props: (size: ButtonSize) => ({ size }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'focus',
'disabled+focus',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'active':
return { active: true };
case 'disabled+focus':
return { focus: true, disabled: true };
default:
return {};
}
},
},
{
name: 'accents',
values: ['default', 'blue', 'danger'] satisfies ButtonAccent[],
props: (accent: ButtonAccent) => ({ accent }),
},
{
name: 'variants',
values: [
'primary',
'secondary',
'tertiary',
] satisfies ButtonVariant[],
props: (variant: ButtonVariant) => ({ variant }),
},
],
},
@ -111,20 +181,95 @@ export const Positions: Story = {
decorators: [CatalogDecorator],
};
export const WithAdornments: Story = {
export const PositionCatalog: Story = {
args: { title: 'Filter', icon: <IconSearch /> },
argTypes: {
size: { control: false },
variant: { control: false },
accent: { control: false },
disabled: { control: false },
focus: { control: false },
fullWidth: { control: false },
soon: { control: false },
position: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
catalog: {
dimensions: [
{
name: 'adornments',
values: ['with icon', 'with soon pill'],
props: (value: string) =>
value === 'with icon'
? { icon: <IconSearch size={14} /> }
: { soon: true },
name: 'positions',
values: [
'standalone',
'left',
'middle',
'right',
] satisfies ButtonPosition[],
props: (position: ButtonPosition) => ({ position }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'focus',
'disabled+focus',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'active':
return { active: true };
case 'disabled+focus':
return { focus: true, disabled: true };
default:
return {};
}
},
},
{
name: 'sizes',
values: ['small', 'medium'] satisfies ButtonSize[],
props: (size: ButtonSize) => ({ size }),
},
{
name: 'variants',
values: [
'primary',
'secondary',
'tertiary',
] satisfies ButtonVariant[],
props: (variant: ButtonVariant) => ({ variant }),
},
],
},
},
decorators: [CatalogDecorator],
};
export const FullWidth: Story = {
args: { title: 'Filter', icon: <IconSearch />, fullWidth: true },
argTypes: {
size: { control: false },
variant: { control: false },
accent: { control: false },
focus: { control: false },
disabled: { control: false },
fullWidth: { control: false },
soon: { control: false },
position: { control: false },
className: { control: false },
icon: { control: false },
},
decorators: [ComponentDecorator],
};

View File

@ -1,36 +1,76 @@
import { expect, jest } from '@storybook/jest';
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { Button, ButtonPosition } from '../Button';
import { Button, ButtonAccent, ButtonSize, ButtonVariant } from '../Button';
import { ButtonGroup } from '../ButtonGroup';
const clickJestFn = jest.fn();
const meta: Meta<typeof ButtonGroup> = {
title: 'UI/Button/ButtonGroup',
component: ButtonGroup,
decorators: [ComponentDecorator],
argTypes: { children: { control: false } },
args: {
children: Object.values(ButtonPosition).map((position) => (
<Button title={position} onClick={clickJestFn} />
)),
},
};
export default meta;
type Story = StoryObj<typeof ButtonGroup>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const leftButton = canvas.getByRole('button', { name: 'left' });
const numberOfClicks = clickJestFn.mock.calls.length;
await userEvent.click(leftButton);
expect(clickJestFn).toHaveBeenCalledTimes(numberOfClicks + 1);
args: {
size: 'small',
variant: 'primary',
accent: 'danger',
children: [
<Button icon={<IconNotes />} title="Note" />,
<Button icon={<IconCheckbox />} title="Task" />,
<Button icon={<IconTimelineEvent />} title="Activity" />,
],
},
argTypes: {
children: { control: false },
},
decorators: [ComponentDecorator],
};
export const Catalog: Story = {
args: {
children: [
<Button icon={<IconNotes />} title="Note" />,
<Button icon={<IconCheckbox />} title="Task" />,
<Button icon={<IconTimelineEvent />} title="Activity" />,
],
},
argTypes: {
size: { control: false },
variant: { control: false },
accent: { control: false },
children: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies ButtonSize[],
props: (size: ButtonSize) => ({ size }),
},
{
name: 'accents',
values: ['default', 'blue', 'danger'] satisfies ButtonAccent[],
props: (accent: ButtonAccent) => ({ accent }),
},
{
name: 'variants',
values: [
'primary',
'secondary',
'tertiary',
] satisfies ButtonVariant[],
props: (variant: ButtonVariant) => ({ variant }),
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -0,0 +1,83 @@
import type { Meta, StoryObj } from '@storybook/react';
import { IconSearch } from '@/ui/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { FloatingButton, FloatingButtonSize } from '../FloatingButton';
const meta: Meta<typeof FloatingButton> = {
title: 'UI/Button/FloatingButton',
component: FloatingButton,
};
export default meta;
type Story = StoryObj<typeof FloatingButton>;
export const Default: Story = {
args: {
title: 'Filter',
size: 'small',
disabled: false,
focus: false,
applyBlur: true,
applyShadow: true,
position: 'standalone',
icon: <IconSearch />,
},
argTypes: {
icon: { control: false },
},
decorators: [ComponentDecorator],
};
export const Catalog: Story = {
args: { title: 'Filter', icon: <IconSearch /> },
argTypes: {
size: { control: false },
disabled: { control: false },
position: { control: false },
focus: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies FloatingButtonSize[],
props: (size: FloatingButtonSize) => ({ size }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'focus',
'disabled+focus',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'disabled+focus':
return { disabled: true, focus: true };
default:
return {};
}
},
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -0,0 +1,58 @@
import type { Meta, StoryObj } from '@storybook/react';
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { FloatingButton, FloatingButtonSize } from '../FloatingButton';
import { FloatingButtonGroup } from '../FloatingButtonGroup';
const meta: Meta<typeof FloatingButtonGroup> = {
title: 'UI/Button/FloatingButtonGroup',
component: FloatingButtonGroup,
};
export default meta;
type Story = StoryObj<typeof FloatingButtonGroup>;
export const Default: Story = {
args: {
size: 'small',
children: [
<FloatingButton icon={<IconNotes />} />,
<FloatingButton icon={<IconCheckbox />} />,
<FloatingButton icon={<IconTimelineEvent />} />,
],
},
argTypes: {
children: { control: false },
},
decorators: [ComponentDecorator],
};
export const Catalog: Story = {
args: {
children: [
<FloatingButton icon={<IconNotes />} />,
<FloatingButton icon={<IconCheckbox />} />,
<FloatingButton icon={<IconTimelineEvent />} />,
],
},
argTypes: {
size: { control: false },
children: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies FloatingButtonSize[],
props: (size: FloatingButtonSize) => ({ size }),
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -0,0 +1,84 @@
import type { Meta, StoryObj } from '@storybook/react';
import { IconSearch } from '@/ui/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import {
FloatingIconButton,
FloatingIconButtonSize,
} from '../FloatingIconButton';
const meta: Meta<typeof FloatingIconButton> = {
title: 'UI/Button/FloatingIconButton',
component: FloatingIconButton,
};
export default meta;
type Story = StoryObj<typeof FloatingIconButton>;
export const Default: Story = {
args: {
size: 'small',
disabled: false,
focus: false,
applyBlur: true,
applyShadow: true,
position: 'standalone',
icon: <IconSearch />,
},
argTypes: {
icon: { control: false },
},
decorators: [ComponentDecorator],
};
export const Catalog: Story = {
args: { icon: <IconSearch /> },
argTypes: {
size: { control: false },
disabled: { control: false },
focus: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies FloatingIconButtonSize[],
props: (size: FloatingIconButtonSize) => ({ size }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'focus',
'disabled+focus',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'disabled+focus':
return { disabled: true, focus: true };
default:
return {};
}
},
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -0,0 +1,61 @@
import type { Meta, StoryObj } from '@storybook/react';
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import {
FloatingIconButton,
FloatingIconButtonSize,
} from '../FloatingIconButton';
import { FloatingIconButtonGroup } from '../FloatingIconButtonGroup';
const meta: Meta<typeof FloatingIconButtonGroup> = {
title: 'UI/Button/FloatingIconButtonGroup',
component: FloatingIconButtonGroup,
};
export default meta;
type Story = StoryObj<typeof FloatingIconButtonGroup>;
export const Default: Story = {
args: {
size: 'small',
children: [
<FloatingIconButton icon={<IconNotes />} />,
<FloatingIconButton icon={<IconCheckbox />} />,
<FloatingIconButton icon={<IconTimelineEvent />} />,
],
},
argTypes: {
children: { control: false },
},
decorators: [ComponentDecorator],
};
export const Catalog: Story = {
args: {
children: [
<FloatingIconButton icon={<IconNotes />} />,
<FloatingIconButton icon={<IconCheckbox />} />,
<FloatingIconButton icon={<IconTimelineEvent />} />,
],
},
argTypes: {
size: { control: false },
children: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies FloatingIconButtonSize[],
props: (size: FloatingIconButtonSize) => ({ size }),
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -1,156 +1,179 @@
import styled from '@emotion/styled';
import { expect, jest } from '@storybook/jest';
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { IconUser } from '@/ui/icon';
import { IconSearch } from '@/ui/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { IconButton } from '../IconButton';
type IconButtonProps = React.ComponentProps<typeof IconButton>;
const StyledContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
padding: 20px;
width: 800px;
> * + * {
margin-top: ${({ theme }) => theme.spacing(4)};
}
`;
const StyledTitle = styled.h1`
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(2)};
margin-top: ${({ theme }) => theme.spacing(3)};
`;
const StyledDescription = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-align: center;
text-transform: uppercase;
`;
const StyledLine = styled.div`
display: flex;
flex: 1;
flex-direction: row;
`;
const StyledIconButtonContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(2)};
width: 50px;
`;
import {
IconButton,
IconButtonAccent,
IconButtonPosition,
IconButtonSize,
IconButtonVariant,
} from '../IconButton';
const meta: Meta<typeof IconButton> = {
title: 'UI/Button/IconButton',
component: IconButton,
decorators: [
(Story) => (
<StyledContainer>
<Story />
</StyledContainer>
),
],
argTypes: { icon: { control: false }, variant: { control: false } },
};
export default meta;
type Story = StoryObj<typeof IconButton>;
const variants: IconButtonProps['variant'][] = [
'transparent',
'border',
'shadow',
'white',
];
const clickJestFn = jest.fn();
const states = {
default: {
description: 'Default',
extraProps: (variant: string) => ({
'data-testid': `${variant}-button-default`,
onClick: clickJestFn,
}),
},
hover: {
description: 'Hover',
extraProps: (variant: string) => ({
id: `${variant}-button-hover`,
'data-testid': `${variant}-button-hover`,
}),
},
pressed: {
description: 'Pressed',
extraProps: (variant: string) => ({
id: `${variant}-button-pressed`,
'data-testid': `${variant}-button-pressed`,
}),
},
disabled: {
description: 'Disabled',
extraProps: (variant: string) => ({
'data-testid': `${variant}-button-disabled`,
disabled: true,
}),
export const Default: Story = {
args: {
size: 'small',
variant: 'primary',
accent: 'danger',
disabled: false,
focus: false,
position: 'standalone',
icon: <IconSearch />,
},
decorators: [ComponentDecorator],
};
export const LargeSize: Story = {
args: { size: 'large' },
render: (args) => (
<>
{variants.map((variant) => (
<div key={variant}>
<StyledTitle>{variant}</StyledTitle>
<StyledLine>
{Object.entries(states).map(
([state, { description, extraProps }]) => (
<StyledIconButtonContainer
key={`${variant}-container-${state}`}
>
<StyledDescription>{description}</StyledDescription>
<IconButton
{...args}
{...extraProps(variant ?? '')}
variant={variant}
icon={<IconUser size={args.size === 'small' ? 14 : 16} />}
/>
</StyledIconButtonContainer>
),
)}
</StyledLine>
</div>
))}
</>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByTestId('transparent-button-default');
const numberOfClicks = clickJestFn.mock.calls.length;
await userEvent.click(button);
expect(clickJestFn).toHaveBeenCalledTimes(numberOfClicks + 1);
export const Catalog: Story = {
args: { icon: <IconSearch /> },
argTypes: {
size: { control: false },
variant: { control: false },
focus: { control: false },
accent: { control: false },
disabled: { control: false },
icon: { control: false },
position: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies IconButtonSize[],
props: (size: IconButtonSize) => ({ size }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'focus',
'disabled+focus',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'active':
return { active: true };
case 'disabled+focus':
return { focus: true, disabled: true };
default:
return {};
}
},
},
{
name: 'accents',
values: ['default', 'blue', 'danger'] satisfies IconButtonAccent[],
props: (accent: IconButtonAccent) => ({ accent }),
},
{
name: 'variants',
values: [
'primary',
'secondary',
'tertiary',
] satisfies IconButtonVariant[],
props: (variant: IconButtonVariant) => ({ variant }),
},
],
},
},
decorators: [CatalogDecorator],
};
export const MediumSize: Story = {
...LargeSize,
args: { size: 'medium' },
};
export const SmallSize: Story = {
...LargeSize,
args: { size: 'small' },
export const PositionCatalog: Story = {
args: { icon: <IconSearch /> },
argTypes: {
size: { control: false },
variant: { control: false },
focus: { control: false },
accent: { control: false },
disabled: { control: false },
position: { control: false },
icon: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'] },
catalog: {
dimensions: [
{
name: 'positions',
values: [
'standalone',
'left',
'middle',
'right',
] satisfies IconButtonPosition[],
props: (position: IconButtonPosition) => ({ position }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'focus',
'disabled+focus',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'active':
return { active: true };
case 'disabled+focus':
return { focus: true, disabled: true };
default:
return {};
}
},
},
{
name: 'sizes',
values: ['small', 'medium'] satisfies IconButtonSize[],
props: (size: IconButtonSize) => ({ size }),
},
{
name: 'variants',
values: [
'primary',
'secondary',
'tertiary',
] satisfies IconButtonVariant[],
props: (variant: IconButtonVariant) => ({ variant }),
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -1,10 +1,15 @@
import type { Meta, StoryObj } from '@storybook/react';
import { IconBell } from '@tabler/icons-react';
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { IconButton } from '../IconButton';
import { Button } from '../Button';
import {
IconButtonAccent,
IconButtonSize,
IconButtonVariant,
} from '../IconButton';
import { IconButtonGroup } from '../IconButtonGroup';
const meta: Meta<typeof IconButtonGroup> = {
@ -15,40 +20,58 @@ const meta: Meta<typeof IconButtonGroup> = {
export default meta;
type Story = StoryObj<typeof IconButtonGroup>;
const args = {
children: [
<IconButton icon={<IconBell />} />,
<IconButton icon={<IconBell />} />,
],
};
export const Default: Story = {
args,
args: {
size: 'small',
variant: 'primary',
accent: 'danger',
children: [
<Button icon={<IconNotes />} />,
<Button icon={<IconCheckbox />} />,
<Button icon={<IconTimelineEvent />} />,
],
},
argTypes: {
children: { control: false },
},
decorators: [ComponentDecorator],
};
export const Catalog: Story = {
args,
args: {
children: [
<Button icon={<IconNotes />} />,
<Button icon={<IconCheckbox />} />,
<Button icon={<IconTimelineEvent />} />,
],
},
argTypes: {
size: { control: false },
variant: { control: false },
accent: { control: false },
children: { control: false },
},
parameters: {
catalog: {
dimensions: [
{
name: 'variants',
values: ['transparent', 'border', 'shadow', 'white'],
props: (variant: string) => ({
variant,
}),
name: 'sizes',
values: ['small', 'medium'] satisfies IconButtonSize[],
props: (size: IconButtonSize) => ({ size }),
},
{
name: 'sizes',
values: ['large', 'medium', 'small'],
props: (size: string) => ({
size,
}),
name: 'accents',
values: ['default', 'blue', 'danger'] satisfies IconButtonAccent[],
props: (accent: IconButtonAccent) => ({ accent }),
},
{
name: 'variants',
values: [
'primary',
'secondary',
'tertiary',
] satisfies IconButtonVariant[],
props: (variant: IconButtonVariant) => ({ variant }),
},
],
},

View File

@ -0,0 +1,87 @@
import type { Meta, StoryObj } from '@storybook/react';
import { IconSearch } from '@/ui/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { LightButton, LightButtonAccent } from '../LightButton';
const meta: Meta<typeof LightButton> = {
title: 'UI/Button/LightButton',
component: LightButton,
};
export default meta;
type Story = StoryObj<typeof LightButton>;
export const Default: Story = {
args: {
title: 'Filter',
accent: 'secondary',
disabled: false,
active: false,
focus: false,
icon: <IconSearch />,
},
argTypes: {
icon: { control: false },
},
decorators: [ComponentDecorator],
};
export const Catalog: Story = {
args: { title: 'Filter', icon: <IconSearch /> },
argTypes: {
accent: { control: false },
disabled: { control: false },
active: { control: false },
focus: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'] },
catalog: {
dimensions: [
{
name: 'accents',
values: ['secondary', 'tertiary'] satisfies LightButtonAccent[],
props: (accent: LightButtonAccent) => ({ accent }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'active',
'focus',
'disabled+focus',
'disabled+active',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'active':
return { active: true };
case 'disabled+focus':
return { disabled: true, focus: true };
case 'disabled+active':
return { disabled: true, active: true };
default:
return {};
}
},
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -0,0 +1,96 @@
import type { Meta, StoryObj } from '@storybook/react';
import { IconSearch } from '@/ui/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import {
LightIconButton,
LightIconButtonAccent,
LightIconButtonSize,
} from '../LightIconButton';
const meta: Meta<typeof LightIconButton> = {
title: 'UI/Button/LightIconButton',
component: LightIconButton,
};
export default meta;
type Story = StoryObj<typeof LightIconButton>;
export const Default: Story = {
args: {
title: 'Filter',
accent: 'secondary',
disabled: false,
active: false,
focus: false,
icon: <IconSearch />,
},
argTypes: {
icon: { control: false },
},
decorators: [ComponentDecorator],
};
export const Catalog: Story = {
args: { title: 'Filter', icon: <IconSearch /> },
argTypes: {
accent: { control: false },
disabled: { control: false },
active: { control: false },
focus: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'] },
catalog: {
dimensions: [
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'active',
'focus',
'disabled+focus',
'disabled+active',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'active':
return { active: true };
case 'disabled+focus':
return { disabled: true, focus: true };
case 'disabled+active':
return { disabled: true, active: true };
default:
return {};
}
},
},
{
name: 'accents',
values: ['secondary', 'tertiary'] satisfies LightIconButtonAccent[],
props: (accent: LightIconButtonAccent) => ({ accent }),
},
{
name: 'sizes',
values: ['small', 'medium'] satisfies LightIconButtonSize[],
props: (size: LightIconButtonSize) => ({ size }),
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -12,15 +12,15 @@ const clickJestFn = jest.fn();
const meta: Meta<typeof RoundedIconButton> = {
title: 'UI/Button/RoundedIconButton',
component: RoundedIconButton,
decorators: [ComponentDecorator],
argTypes: { icon: { control: false } },
args: { onClick: clickJestFn, icon: <IconArrowRight size={15} /> },
};
export default meta;
type Story = StoryObj<typeof RoundedIconButton>;
export const Default: Story = {
decorators: [ComponentDecorator],
argTypes: { icon: { control: false } },
args: { onClick: clickJestFn, icon: <IconArrowRight size={15} /> },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);