Migrate to a monorepo structure (#2909)
This commit is contained in:
@ -0,0 +1,40 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export type AnimatedCheckmarkProps = React.ComponentProps<
|
||||
typeof motion.path
|
||||
> & {
|
||||
isAnimating?: boolean;
|
||||
color?: string;
|
||||
duration?: number;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const AnimatedCheckmark = ({
|
||||
isAnimating = false,
|
||||
color,
|
||||
duration = 0.5,
|
||||
size = 28,
|
||||
}: AnimatedCheckmarkProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 52 52"
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
<motion.path
|
||||
fill="none"
|
||||
stroke={color ?? theme.grayScale.gray0}
|
||||
strokeWidth={4}
|
||||
d="M14 27l7.8 7.8L38 14"
|
||||
pathLength="1"
|
||||
strokeDasharray="1"
|
||||
strokeDashoffset={isAnimating ? '1' : '0'}
|
||||
animate={{ strokeDashoffset: isAnimating ? '0' : '1' }}
|
||||
transition={{ duration }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconCheck } from '@/ui/display/icon';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${({ theme }) => theme.color.blue};
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
export type CheckmarkProps = React.ComponentPropsWithoutRef<'div'> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Checkmark = ({ className }: CheckmarkProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledContainer className={className}>
|
||||
<IconCheck color={theme.grayScale.gray0} size={14} />
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,20 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
|
||||
import { Checkmark } from '../Checkmark';
|
||||
|
||||
const meta: Meta<typeof Checkmark> = {
|
||||
title: 'UI/Display/Checkmark/Checkmark',
|
||||
component: Checkmark,
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Checkmark>;
|
||||
|
||||
export const Default: Story = { args: {} };
|
||||
|
||||
export const WithCustomStyles: Story = {
|
||||
args: { style: { backgroundColor: 'red', height: 40, width: 40 } },
|
||||
};
|
||||
@ -0,0 +1,148 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { OverflowingTextWithTooltip } from '../../tooltip/OverflowingTextWithTooltip';
|
||||
|
||||
export enum ChipSize {
|
||||
Large = 'large',
|
||||
Small = 'small',
|
||||
}
|
||||
|
||||
export enum ChipAccent {
|
||||
TextPrimary = 'text-primary',
|
||||
TextSecondary = 'text-secondary',
|
||||
}
|
||||
|
||||
export enum ChipVariant {
|
||||
Highlighted = 'highlighted',
|
||||
Regular = 'regular',
|
||||
Transparent = 'transparent',
|
||||
Rounded = 'rounded',
|
||||
}
|
||||
|
||||
type ChipProps = {
|
||||
size?: ChipSize;
|
||||
disabled?: boolean;
|
||||
clickable?: boolean;
|
||||
label: string;
|
||||
maxWidth?: string;
|
||||
variant?: ChipVariant;
|
||||
accent?: ChipAccent;
|
||||
leftComponent?: ReactNode;
|
||||
rightComponent?: ReactNode;
|
||||
className?: string;
|
||||
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div<Partial<ChipProps>>`
|
||||
align-items: center;
|
||||
|
||||
background-color: ${({ theme, variant }) =>
|
||||
variant === ChipVariant.Highlighted
|
||||
? theme.background.transparent.light
|
||||
: variant === ChipVariant.Rounded
|
||||
? theme.background.transparent.lighter
|
||||
: 'transparent'};
|
||||
border-color: ${({ theme, variant }) =>
|
||||
variant === ChipVariant.Rounded ? theme.border.color.medium : 'none'};
|
||||
border-radius: ${({ theme, variant }) =>
|
||||
variant === ChipVariant.Rounded ? '50px' : theme.border.radius.sm};
|
||||
border-style: ${({ variant }) =>
|
||||
variant === ChipVariant.Rounded ? 'solid' : 'none'};
|
||||
border-width: ${({ variant }) =>
|
||||
variant === ChipVariant.Rounded ? '1px' : '0px'};
|
||||
|
||||
color: ${({ theme, disabled, accent }) =>
|
||||
disabled
|
||||
? theme.font.color.light
|
||||
: accent === ChipAccent.TextPrimary
|
||||
? theme.font.color.primary
|
||||
: theme.font.color.secondary};
|
||||
cursor: ${({ clickable, disabled, variant }) =>
|
||||
disabled || variant === ChipVariant.Transparent
|
||||
? 'inherit'
|
||||
: clickable
|
||||
? 'pointer'
|
||||
: 'inherit'};
|
||||
display: inline-flex;
|
||||
font-weight: ${({ theme, accent }) =>
|
||||
accent === ChipAccent.TextSecondary ? theme.font.weight.medium : 'inherit'};
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
|
||||
height: ${({ size }) => (size === ChipSize.Large ? '16px' : '12px')};
|
||||
max-width: ${({ maxWidth }) => (maxWidth ? maxWidth : '200px')};
|
||||
|
||||
overflow: hidden;
|
||||
padding: ${({ theme, variant }) =>
|
||||
variant === ChipVariant.Rounded ? '3px 8px' : theme.spacing(1)};
|
||||
user-select: none;
|
||||
|
||||
:hover {
|
||||
${({ variant, theme, disabled }) => {
|
||||
if (!disabled) {
|
||||
return (
|
||||
'background-color: ' +
|
||||
(variant === ChipVariant.Highlighted
|
||||
? theme.background.transparent.medium
|
||||
: variant === ChipVariant.Regular
|
||||
? theme.background.transparent.light
|
||||
: 'transparent') +
|
||||
';'
|
||||
);
|
||||
}
|
||||
}}
|
||||
}
|
||||
:active {
|
||||
${({ variant, theme, disabled }) => {
|
||||
if (!disabled) {
|
||||
return (
|
||||
'background-color: ' +
|
||||
(variant === ChipVariant.Highlighted
|
||||
? theme.background.transparent.strong
|
||||
: variant === ChipVariant.Regular
|
||||
? theme.background.transparent.medium
|
||||
: 'transparent') +
|
||||
';'
|
||||
);
|
||||
}
|
||||
}}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledLabel = styled.span`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const Chip = ({
|
||||
size = ChipSize.Small,
|
||||
label,
|
||||
disabled = false,
|
||||
clickable = true,
|
||||
variant = ChipVariant.Regular,
|
||||
leftComponent,
|
||||
rightComponent,
|
||||
accent = ChipAccent.TextPrimary,
|
||||
maxWidth,
|
||||
className,
|
||||
onClick,
|
||||
}: ChipProps) => (
|
||||
<StyledContainer
|
||||
data-testid="chip"
|
||||
clickable={clickable}
|
||||
variant={variant}
|
||||
accent={accent}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
maxWidth={maxWidth}
|
||||
onClick={onClick}
|
||||
>
|
||||
{leftComponent}
|
||||
<StyledLabel>
|
||||
<OverflowingTextWithTooltip text={label} />
|
||||
</StyledLabel>
|
||||
{rightComponent}
|
||||
</StyledContainer>
|
||||
);
|
||||
@ -0,0 +1,79 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
|
||||
import { Avatar, AvatarType } from '@/users/components/Avatar';
|
||||
|
||||
import { Chip, ChipVariant } from './Chip';
|
||||
|
||||
export type EntityChipProps = {
|
||||
linkToEntity?: string;
|
||||
entityId: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
avatarType?: AvatarType;
|
||||
variant?: EntityChipVariant;
|
||||
LeftIcon?: IconComponent;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export enum EntityChipVariant {
|
||||
Regular = 'regular',
|
||||
Transparent = 'transparent',
|
||||
}
|
||||
|
||||
export const EntityChip = ({
|
||||
linkToEntity,
|
||||
entityId,
|
||||
name,
|
||||
avatarUrl,
|
||||
avatarType = 'rounded',
|
||||
variant = EntityChipVariant.Regular,
|
||||
LeftIcon,
|
||||
className,
|
||||
}: EntityChipProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const handleLinkClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (linkToEntity) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
navigate(linkToEntity);
|
||||
}
|
||||
};
|
||||
|
||||
return isNonEmptyString(name) ? (
|
||||
<Chip
|
||||
label={name}
|
||||
variant={
|
||||
linkToEntity
|
||||
? variant === EntityChipVariant.Regular
|
||||
? ChipVariant.Highlighted
|
||||
: ChipVariant.Regular
|
||||
: ChipVariant.Transparent
|
||||
}
|
||||
leftComponent={
|
||||
LeftIcon ? (
|
||||
<LeftIcon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
|
||||
) : (
|
||||
<Avatar
|
||||
avatarUrl={avatarUrl}
|
||||
colorId={entityId}
|
||||
placeholder={name}
|
||||
size="sm"
|
||||
type={avatarType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
clickable={!!linkToEntity}
|
||||
onClick={handleLinkClick}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,69 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import { Chip, ChipAccent, ChipSize, ChipVariant } from '../Chip';
|
||||
|
||||
const meta: Meta<typeof Chip> = {
|
||||
title: 'UI/Display/Chip/Chip',
|
||||
component: Chip,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Chip>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'Chip test',
|
||||
size: ChipSize.Small,
|
||||
variant: ChipVariant.Highlighted,
|
||||
accent: ChipAccent.TextPrimary,
|
||||
disabled: false,
|
||||
clickable: true,
|
||||
maxWidth: '200px',
|
||||
},
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export const Catalog: CatalogStory<Story, typeof Chip> = {
|
||||
args: { clickable: true, label: 'Hello' },
|
||||
argTypes: {
|
||||
size: { control: false },
|
||||
variant: { control: false },
|
||||
disabled: { control: false },
|
||||
className: { control: false },
|
||||
rightComponent: { control: false },
|
||||
leftComponent: { control: false },
|
||||
},
|
||||
parameters: {
|
||||
pseudo: { hover: ['.hover'], active: ['.active'] },
|
||||
catalog: {
|
||||
dimensions: [
|
||||
{
|
||||
name: 'states',
|
||||
values: ['default', 'hover', 'active', 'disabled'],
|
||||
props: (state: string) =>
|
||||
state === 'default' ? {} : { className: state },
|
||||
},
|
||||
{
|
||||
name: 'variants',
|
||||
values: Object.values(ChipVariant),
|
||||
props: (variant: ChipVariant) => ({ variant }),
|
||||
},
|
||||
{
|
||||
name: 'sizes',
|
||||
values: Object.values(ChipSize),
|
||||
props: (size: ChipSize) => ({ size }),
|
||||
},
|
||||
{
|
||||
name: 'accents',
|
||||
values: Object.values(ChipAccent),
|
||||
props: (accent: ChipAccent) => ({ accent }),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [CatalogDecorator],
|
||||
};
|
||||
@ -0,0 +1,21 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
|
||||
|
||||
import { EntityChip } from '../EntityChip';
|
||||
|
||||
const meta: Meta<typeof EntityChip> = {
|
||||
title: 'UI/Display/Chip/EntityChip',
|
||||
component: EntityChip,
|
||||
decorators: [ComponentWithRouterDecorator],
|
||||
args: {
|
||||
name: 'Entity name',
|
||||
linkToEntity: '/entity-link',
|
||||
avatarType: 'squared',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EntityChip>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@ -0,0 +1,39 @@
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { ThemeColor } from '@/ui/theme/constants/colors';
|
||||
|
||||
export type ColorSampleVariant = 'default' | 'pipeline';
|
||||
|
||||
const StyledColorSample = styled.div<{
|
||||
colorName: ThemeColor;
|
||||
variant?: ColorSampleVariant;
|
||||
}>`
|
||||
background-color: ${({ theme, colorName }) =>
|
||||
theme.tag.background[colorName]};
|
||||
border: 1px solid ${({ theme, colorName }) => theme.tag.text[colorName]};
|
||||
border-radius: 60px;
|
||||
height: ${({ theme }) => theme.spacing(4)};
|
||||
width: ${({ theme }) => theme.spacing(3)};
|
||||
|
||||
${({ colorName, theme, variant }) => {
|
||||
if (variant === 'pipeline')
|
||||
return css`
|
||||
align-items: center;
|
||||
border: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
&:after {
|
||||
background-color: ${theme.tag.text[colorName]};
|
||||
border-radius: ${theme.border.radius.rounded};
|
||||
content: '';
|
||||
display: block;
|
||||
height: ${theme.spacing(1)};
|
||||
width: ${theme.spacing(1)};
|
||||
}
|
||||
`;
|
||||
}}
|
||||
`;
|
||||
|
||||
export { StyledColorSample as ColorSample };
|
||||
@ -0,0 +1,25 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
|
||||
import { ColorSample } from '../ColorSample';
|
||||
|
||||
const meta: Meta<typeof ColorSample> = {
|
||||
title: 'UI/Display/Color/ColorSample',
|
||||
component: ColorSample,
|
||||
decorators: [ComponentDecorator],
|
||||
args: { colorName: 'green' },
|
||||
argTypes: {
|
||||
as: { control: false },
|
||||
theme: { control: false },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ColorSample>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Pipeline: Story = {
|
||||
args: { variant: 'pipeline' },
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-address-book" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M20 6v12a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2z" />
|
||||
<path d="M10 16h6" />
|
||||
<path d="M13 11m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M4 8h3" />
|
||||
<path d="M4 12h3" />
|
||||
<path d="M4 16h3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 536 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 84 KiB |
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M6.06216 1.53416C6.38434 0.663593 7.61566 0.663591 7.93784 1.53416L9.00134 4.40789C9.10263 4.68158 9.31842 4.89737 9.59211 4.99866L12.4658 6.06216C13.3364 6.38434 13.3364 7.61566 12.4658 7.93784L9.59211 9.00134C9.31842 9.10263 9.10263 9.31842 9.00134 9.59211L7.93784 12.4658C7.61566 13.3364 6.38434 13.3364 6.06216 12.4658L4.99866 9.59211C4.89737 9.31842 4.68158 9.10263 4.40789 9.00134L1.53416 7.93784C0.663593 7.61566 0.663591 6.38434 1.53416 6.06216L4.40789 4.99866C4.68158 4.89737 4.89737 4.68158 4.99866 4.40789L6.06216 1.53416Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 668 B |
@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.4673 3.06709C10.9938 1.64431 13.0062 1.6443 13.5327 3.06709L15.2708 7.76367C15.4364 8.21097 15.789 8.56364 16.2363 8.72917L20.9329 10.4673C22.3557 10.9938 22.3557 13.0062 20.9329 13.5327L16.2363 15.2708C15.789 15.4364 15.4364 15.789 15.2708 16.2363L13.5327 20.9329C13.0062 22.3557 10.9938 22.3557 10.4673 20.9329L8.72917 16.2363C8.56364 15.789 8.21097 15.4364 7.76367 15.2708L3.06709 13.5327C1.64431 13.0062 1.6443 10.9938 3.06709 10.4673L7.76367 8.72917C8.21097 8.56364 8.56364 8.21097 8.72917 7.76367L10.4673 3.06709Z" stroke="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 663 B |
@ -0,0 +1,12 @@
|
||||
import { TablerIconsProps } from '@/ui/display/icon';
|
||||
|
||||
import IconAddressBookRaw from '../assets/address-book.svg?react';
|
||||
|
||||
type IconAddressBookProps = TablerIconsProps;
|
||||
|
||||
export const IconAddressBook = (props: IconAddressBookProps): JSX.Element => {
|
||||
const size = props.size ?? 24;
|
||||
const stroke = props.stroke ?? 2;
|
||||
|
||||
return <IconAddressBookRaw height={size} width={size} strokeWidth={stroke} />;
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
|
||||
import IconGoogleRaw from '../assets/google-icon.svg?react';
|
||||
|
||||
interface IconGoogleProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const IconGoogle = (props: IconGoogleProps): JSX.Element => {
|
||||
const theme = useTheme();
|
||||
const size = props.size ?? theme.icon.size.lg;
|
||||
|
||||
return <IconGoogleRaw height={size} width={size} />;
|
||||
};
|
||||
@ -0,0 +1,12 @@
|
||||
import { TablerIconsProps } from '@/ui/display/icon';
|
||||
|
||||
import IconTwentyStarRaw from '../assets/twenty-star.svg?react';
|
||||
|
||||
type IconTwentyStarProps = TablerIconsProps;
|
||||
|
||||
export const IconTwentyStar = (props: IconTwentyStarProps): JSX.Element => {
|
||||
const size = props.size ?? 24;
|
||||
const stroke = props.stroke ?? 2;
|
||||
|
||||
return <IconTwentyStarRaw height={size} width={size} strokeWidth={stroke} />;
|
||||
};
|
||||
@ -0,0 +1,16 @@
|
||||
import { TablerIconsProps } from '@/ui/display/icon';
|
||||
|
||||
import IconTwentyStarFilledRaw from '../assets/twenty-star-filled.svg?react';
|
||||
|
||||
type IconTwentyStarFilledProps = TablerIconsProps;
|
||||
|
||||
export const IconTwentyStarFilled = (
|
||||
props: IconTwentyStarFilledProps,
|
||||
): JSX.Element => {
|
||||
const size = props.size ?? 24;
|
||||
const stroke = props.stroke ?? 2;
|
||||
|
||||
return (
|
||||
<IconTwentyStarFilledRaw height={size} width={size} strokeWidth={stroke} />
|
||||
);
|
||||
};
|
||||
94
packages/twenty-front/src/modules/ui/display/icon/index.ts
Normal file
94
packages/twenty-front/src/modules/ui/display/icon/index.ts
Normal file
@ -0,0 +1,94 @@
|
||||
/* eslint-disable no-restricted-imports */
|
||||
export type { TablerIconsProps } from '@tabler/icons-react';
|
||||
export {
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconArchive,
|
||||
IconArchiveOff,
|
||||
IconArrowDown,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconArrowUp,
|
||||
IconArrowUpRight,
|
||||
IconAt,
|
||||
IconBaselineDensitySmall,
|
||||
IconBell,
|
||||
IconBox,
|
||||
IconBrandGithub,
|
||||
IconBrandGoogle,
|
||||
IconBrandLinkedin,
|
||||
IconBrandX,
|
||||
IconBriefcase,
|
||||
IconBuildingSkyscraper,
|
||||
IconCalendar,
|
||||
IconCalendarEvent,
|
||||
IconCheck,
|
||||
IconCheckbox,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsRight,
|
||||
IconChevronUp,
|
||||
IconCircleDot,
|
||||
IconCoins,
|
||||
IconColorSwatch,
|
||||
IconMessageCircle as IconComment,
|
||||
IconCopy,
|
||||
IconCurrencyDollar,
|
||||
IconDatabase,
|
||||
IconDotsVertical,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconFileCheck,
|
||||
IconFileImport,
|
||||
IconFileUpload,
|
||||
IconForbid,
|
||||
IconGripVertical,
|
||||
IconHeart,
|
||||
IconHeartOff,
|
||||
IconHelpCircle,
|
||||
IconHierarchy2,
|
||||
IconKey,
|
||||
IconLanguage,
|
||||
IconLayoutKanban,
|
||||
IconLayoutSidebarLeftCollapse,
|
||||
IconLayoutSidebarRightCollapse,
|
||||
IconLayoutSidebarRightExpand,
|
||||
IconLink,
|
||||
IconLinkOff,
|
||||
IconList,
|
||||
IconLogout,
|
||||
IconMail,
|
||||
IconMailCog,
|
||||
IconMap,
|
||||
IconMinus,
|
||||
IconMoneybag,
|
||||
IconMouse2,
|
||||
IconNotes,
|
||||
IconNumbers,
|
||||
IconPaperclip,
|
||||
IconPencil,
|
||||
IconPhone,
|
||||
IconPlug,
|
||||
IconPlus,
|
||||
IconProgressCheck,
|
||||
IconRelationManyToMany,
|
||||
IconRelationOneToMany,
|
||||
IconRelationOneToOne,
|
||||
IconRepeat,
|
||||
IconRobot,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconTag,
|
||||
IconTarget,
|
||||
IconTargetArrow,
|
||||
IconTextSize,
|
||||
IconTimelineEvent,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
@ -0,0 +1,8 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
|
||||
export type IconComponent = FunctionComponent<{
|
||||
className?: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
stroke?: number;
|
||||
}>;
|
||||
@ -0,0 +1,25 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
type SoonPillProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const StyledSoonPill = styled.span`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.transparent.light};
|
||||
border-radius: ${({ theme }) => theme.border.radius.pill};
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
display: inline-block;
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
font-style: normal;
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
height: ${({ theme }) => theme.spacing(4)};
|
||||
justify-content: flex-end;
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.lg};
|
||||
padding: ${({ theme }) => `0 ${theme.spacing(2)}`};
|
||||
`;
|
||||
|
||||
export const SoonPill = ({ className }: SoonPillProps) => (
|
||||
<StyledSoonPill className={className}>Soon</StyledSoonPill>
|
||||
);
|
||||
@ -0,0 +1,16 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
|
||||
import { SoonPill } from '../SoonPill';
|
||||
|
||||
const meta: Meta<typeof SoonPill> = {
|
||||
title: 'UI/Display/Pill/SoonPill',
|
||||
component: SoonPill,
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SoonPill>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@ -0,0 +1,55 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { ThemeColor } from '@/ui/theme/constants/colors';
|
||||
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
|
||||
|
||||
const StyledStatus = styled.h3<{
|
||||
color: ThemeColor;
|
||||
}>`
|
||||
align-items: center;
|
||||
background: ${({ color, theme }) => theme.tag.background[color]};
|
||||
border-radius: ${({ theme }) => theme.border.radius.pill};
|
||||
color: ${({ color, theme }) => theme.tag.text[color]};
|
||||
display: inline-flex;
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-style: normal;
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
height: ${({ theme }) => theme.spacing(5)};
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
&:before {
|
||||
background-color: ${({ color, theme }) => theme.tag.text[color]};
|
||||
border-radius: ${({ theme }) => theme.border.radius.rounded};
|
||||
content: '';
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
height: ${({ theme }) => theme.spacing(1)};
|
||||
width: ${({ theme }) => theme.spacing(1)};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContent = styled.span`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
type StatusProps = {
|
||||
className?: string;
|
||||
color: ThemeColor;
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const Status = ({ className, color, text, onClick }: StatusProps) => (
|
||||
<StyledStatus
|
||||
className={className}
|
||||
color={themeColorSchema.catch('gray').parse(color)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<StyledContent>{text}</StyledContent>
|
||||
</StyledStatus>
|
||||
);
|
||||
@ -0,0 +1,65 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { expect, fn, userEvent, within } from '@storybook/test';
|
||||
|
||||
import { mainColorNames, ThemeColor } from '@/ui/theme/constants/colors';
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import { Status } from '../Status';
|
||||
|
||||
const meta: Meta<typeof Status> = {
|
||||
title: 'UI/Display/Status/Status',
|
||||
component: Status,
|
||||
args: {
|
||||
text: 'Urgent',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Status>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
color: 'red',
|
||||
onClick: fn(),
|
||||
},
|
||||
decorators: [ComponentDecorator],
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const status = canvas.getByRole('heading', { level: 3 });
|
||||
|
||||
await userEvent.click(status);
|
||||
expect(args.onClick).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLongText: Story = {
|
||||
decorators: [ComponentDecorator],
|
||||
args: {
|
||||
color: 'green',
|
||||
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
|
||||
},
|
||||
parameters: {
|
||||
container: { width: 100 },
|
||||
},
|
||||
};
|
||||
|
||||
export const Catalog: CatalogStory<Story, typeof Status> = {
|
||||
argTypes: {
|
||||
color: { control: false },
|
||||
},
|
||||
parameters: {
|
||||
catalog: {
|
||||
dimensions: [
|
||||
{
|
||||
name: 'colors',
|
||||
values: mainColorNames,
|
||||
props: (color: ThemeColor) => ({ color }),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [CatalogDecorator],
|
||||
};
|
||||
@ -0,0 +1,44 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { ThemeColor } from '@/ui/theme/constants/colors';
|
||||
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
|
||||
|
||||
const StyledTag = styled.h3<{
|
||||
color: ThemeColor;
|
||||
}>`
|
||||
align-items: center;
|
||||
background: ${({ color, theme }) => theme.tag.background[color]};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
color: ${({ color, theme }) => theme.tag.text[color]};
|
||||
display: inline-flex;
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-style: normal;
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
height: ${({ theme }) => theme.spacing(5)};
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledContent = styled.span`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
type TagProps = {
|
||||
className?: string;
|
||||
color: ThemeColor;
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const Tag = ({ className, color, text, onClick }: TagProps) => (
|
||||
<StyledTag
|
||||
className={className}
|
||||
color={themeColorSchema.catch('gray').parse(color)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<StyledContent>{text}</StyledContent>
|
||||
</StyledTag>
|
||||
);
|
||||
@ -0,0 +1,65 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { expect, fn, userEvent, within } from '@storybook/test';
|
||||
|
||||
import { mainColorNames, ThemeColor } from '@/ui/theme/constants/colors';
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import { Tag } from '../Tag';
|
||||
|
||||
const meta: Meta<typeof Tag> = {
|
||||
title: 'UI/Display/Tag/Tag',
|
||||
component: Tag,
|
||||
args: {
|
||||
text: 'Urgent',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Tag>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
color: 'red',
|
||||
onClick: fn(),
|
||||
},
|
||||
decorators: [ComponentDecorator],
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const tag = canvas.getByRole('heading', { level: 3 });
|
||||
|
||||
await userEvent.click(tag);
|
||||
await expect(args.onClick).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLongText: Story = {
|
||||
decorators: [ComponentDecorator],
|
||||
args: {
|
||||
color: 'green',
|
||||
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
|
||||
},
|
||||
parameters: {
|
||||
container: { width: 100 },
|
||||
},
|
||||
};
|
||||
|
||||
export const Catalog: CatalogStory<Story, typeof Tag> = {
|
||||
argTypes: {
|
||||
color: { control: false },
|
||||
},
|
||||
parameters: {
|
||||
catalog: {
|
||||
dimensions: [
|
||||
{
|
||||
name: 'colors',
|
||||
values: mainColorNames,
|
||||
props: (color: ThemeColor) => ({ color }),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [CatalogDecorator],
|
||||
};
|
||||
@ -0,0 +1,70 @@
|
||||
import { PlacesType, PositionStrategy, Tooltip } from 'react-tooltip';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { rgba } from '../../theme/constants/colors';
|
||||
|
||||
export enum TooltipPosition {
|
||||
Top = 'top',
|
||||
Left = 'left',
|
||||
Right = 'right',
|
||||
Bottom = 'bottom',
|
||||
}
|
||||
|
||||
const StyledAppTooltip = styled(Tooltip)`
|
||||
backdrop-filter: ${({ theme }) => theme.blur.strong};
|
||||
background-color: ${({ theme }) => rgba(theme.color.gray80, 0.8)};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
|
||||
box-shadow: ${({ theme }) => theme.boxShadow.light};
|
||||
color: ${({ theme }) => theme.grayScale.gray0};
|
||||
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
|
||||
max-width: 40%;
|
||||
overflow: visible;
|
||||
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
word-break: break-word;
|
||||
|
||||
z-index: ${({ theme }) => theme.lastLayerZIndex};
|
||||
`;
|
||||
|
||||
export type AppTooltipProps = {
|
||||
className?: string;
|
||||
anchorSelect?: string;
|
||||
content?: string;
|
||||
delayHide?: number;
|
||||
offset?: number;
|
||||
noArrow?: boolean;
|
||||
isOpen?: boolean;
|
||||
place?: PlacesType;
|
||||
positionStrategy?: PositionStrategy;
|
||||
};
|
||||
|
||||
export const AppTooltip = ({
|
||||
anchorSelect,
|
||||
className,
|
||||
content,
|
||||
delayHide,
|
||||
isOpen,
|
||||
noArrow,
|
||||
offset,
|
||||
place,
|
||||
positionStrategy,
|
||||
}: AppTooltipProps) => (
|
||||
<StyledAppTooltip
|
||||
{...{
|
||||
anchorSelect,
|
||||
className,
|
||||
content,
|
||||
delayHide,
|
||||
isOpen,
|
||||
noArrow,
|
||||
offset,
|
||||
place,
|
||||
positionStrategy,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import styled from '@emotion/styled';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
|
||||
import { AppTooltip } from './AppTooltip';
|
||||
|
||||
const StyledOverflowingText = styled.div<{ cursorPointer: boolean }>`
|
||||
cursor: ${({ cursorPointer }) => (cursorPointer ? 'pointer' : 'inherit')};
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
|
||||
font-weight: inherit;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-decoration: inherit;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const OverflowingTextWithTooltip = ({
|
||||
text,
|
||||
className,
|
||||
}: {
|
||||
text: string | null | undefined;
|
||||
className?: string;
|
||||
}) => {
|
||||
const textElementId = `title-id-${uuidV4()}`;
|
||||
|
||||
const textRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isTitleOverflowing, setIsTitleOverflowing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isOverflowing =
|
||||
(text?.length ?? 0) > 0 && textRef.current
|
||||
? textRef.current?.scrollHeight > textRef.current?.clientHeight ||
|
||||
textRef.current.scrollWidth > textRef.current.clientWidth
|
||||
: false;
|
||||
|
||||
if (isTitleOverflowing !== isOverflowing) {
|
||||
setIsTitleOverflowing(isOverflowing);
|
||||
}
|
||||
}, [isTitleOverflowing, text]);
|
||||
|
||||
const handleTooltipClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledOverflowingText
|
||||
data-testid="tooltip"
|
||||
className={className}
|
||||
ref={textRef}
|
||||
id={textElementId}
|
||||
cursorPointer={isTitleOverflowing}
|
||||
>
|
||||
{text}
|
||||
</StyledOverflowingText>
|
||||
{isTitleOverflowing &&
|
||||
createPortal(
|
||||
<div onClick={handleTooltipClick}>
|
||||
<AppTooltip
|
||||
anchorSelect={`#${textElementId}`}
|
||||
content={text ?? ''}
|
||||
delayHide={0}
|
||||
offset={5}
|
||||
noArrow
|
||||
place="bottom"
|
||||
positionStrategy="absolute"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { userEvent, within } from '@storybook/test';
|
||||
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
|
||||
import { OverflowingTextWithTooltip } from '../OverflowingTextWithTooltip';
|
||||
|
||||
const placeholderText =
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi tellus diam, rhoncus nec consequat quis, dapibus quis massa. Praesent tincidunt augue at ex bibendum, non finibus augue faucibus. In at gravida orci. Nulla facilisi. Proin ut augue ut nisi pellentesque tristique. Proin sodales libero id turpis tincidunt posuere.';
|
||||
|
||||
const meta: Meta<typeof OverflowingTextWithTooltip> = {
|
||||
title: 'UI/Display/Tooltip/OverflowingTextWithTooltip',
|
||||
component: OverflowingTextWithTooltip,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof OverflowingTextWithTooltip>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
text: placeholderText,
|
||||
},
|
||||
decorators: [ComponentDecorator],
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const tooltip = await canvas.findByTestId('tooltip');
|
||||
userEvent.hover(tooltip);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,82 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import { AppTooltip as Tooltip, TooltipPosition } from '../AppTooltip';
|
||||
|
||||
const meta: Meta<typeof Tooltip> = {
|
||||
title: 'UI/Display/Tooltip/Tooltip',
|
||||
component: Tooltip,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Tooltip>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
place: TooltipPosition.Bottom,
|
||||
content: 'Tooltip Test',
|
||||
isOpen: true,
|
||||
anchorSelect: '#hover-text',
|
||||
},
|
||||
decorators: [ComponentDecorator],
|
||||
render: ({
|
||||
anchorSelect,
|
||||
className,
|
||||
content,
|
||||
delayHide,
|
||||
isOpen,
|
||||
noArrow,
|
||||
offset,
|
||||
place,
|
||||
positionStrategy,
|
||||
}) => (
|
||||
<>
|
||||
<p id="hover-text" data-testid="tooltip">
|
||||
Hover me!
|
||||
</p>
|
||||
<Tooltip
|
||||
{...{
|
||||
anchorSelect,
|
||||
className,
|
||||
content,
|
||||
delayHide,
|
||||
isOpen,
|
||||
noArrow,
|
||||
offset,
|
||||
place,
|
||||
positionStrategy,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export const Catalog: CatalogStory<Story, typeof Tooltip> = {
|
||||
args: { isOpen: true, content: 'Tooltip Test' },
|
||||
play: async ({ canvasElement }) => {
|
||||
Object.values(TooltipPosition).forEach((position) => {
|
||||
const element = canvasElement.querySelector(
|
||||
`#${position}`,
|
||||
) as HTMLElement;
|
||||
element.style.margin = '75px';
|
||||
});
|
||||
},
|
||||
parameters: {
|
||||
catalog: {
|
||||
dimensions: [
|
||||
{
|
||||
name: 'anchorSelect',
|
||||
values: Object.values(TooltipPosition),
|
||||
props: (anchorSelect: TooltipPosition) => ({
|
||||
anchorSelect: `#${anchorSelect}`,
|
||||
place: anchorSelect,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [CatalogDecorator],
|
||||
};
|
||||
@ -0,0 +1,36 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
type H1TitleProps = {
|
||||
title: string;
|
||||
fontColor?: H1TitleFontColor;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export enum H1TitleFontColor {
|
||||
Primary = 'primary',
|
||||
Secondary = 'secondary',
|
||||
Tertiary = 'tertiary',
|
||||
}
|
||||
|
||||
const StyledTitle = styled.h2<{
|
||||
fontColor: H1TitleFontColor;
|
||||
}>`
|
||||
color: ${({ theme, fontColor }) => theme.font.color[fontColor]};
|
||||
font-size: ${({ theme }) => theme.font.size.lg};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.md};
|
||||
margin: 0;
|
||||
margin-bottom: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
export const H1Title = ({
|
||||
title,
|
||||
fontColor = H1TitleFontColor.Tertiary,
|
||||
className,
|
||||
}: H1TitleProps) => {
|
||||
return (
|
||||
<StyledTitle fontColor={fontColor} className={className}>
|
||||
{title}
|
||||
</StyledTitle>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,50 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
type H2TitleProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
addornment?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const StyledTitleContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.h2`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const StyledDescription = styled.h3`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
margin: 0;
|
||||
margin-top: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
export const H2Title = ({
|
||||
title,
|
||||
description,
|
||||
addornment,
|
||||
className,
|
||||
}: H2TitleProps) => (
|
||||
<StyledContainer className={className}>
|
||||
<StyledTitleContainer>
|
||||
<StyledTitle>{title}</StyledTitle>
|
||||
{addornment}
|
||||
</StyledTitleContainer>
|
||||
{description && <StyledDescription>{description}</StyledDescription>}
|
||||
</StyledContainer>
|
||||
);
|
||||
@ -0,0 +1,43 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import { H1Title, H1TitleFontColor } from '../H1Title';
|
||||
|
||||
const meta: Meta<typeof H1Title> = {
|
||||
title: 'UI/Display/Typography/Title/H1Title',
|
||||
component: H1Title,
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof H1Title>;
|
||||
|
||||
const args = {
|
||||
title: 'Title',
|
||||
fontColor: H1TitleFontColor.Primary,
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args,
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export const Catalog: CatalogStory<Story, typeof H1Title> = {
|
||||
args,
|
||||
decorators: [CatalogDecorator],
|
||||
parameters: {
|
||||
catalog: {
|
||||
dimensions: [
|
||||
{
|
||||
name: 'FontColor',
|
||||
values: Object.values(H1TitleFontColor),
|
||||
props: (fontColor: H1TitleFontColor) => ({ fontColor }),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
|
||||
import { H2Title } from '../H2Title';
|
||||
|
||||
const args = {
|
||||
title: 'Sub title',
|
||||
description: 'Lorem ipsum dolor sit amet',
|
||||
};
|
||||
|
||||
const meta: Meta<typeof H2Title> = {
|
||||
title: 'UI/Display/Typography/Title/H2Title',
|
||||
component: H2Title,
|
||||
decorators: [ComponentDecorator],
|
||||
args: {
|
||||
title: args.title,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof H2Title>;
|
||||
|
||||
export const Default: Story = {
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args,
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
@ -0,0 +1,162 @@
|
||||
import { useCallback } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { Button } from '@/ui/input/button/components/Button';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
|
||||
import { DialogHotkeyScope } from '../types/DialogHotkeyScope';
|
||||
|
||||
const StyledDialogOverlay = styled(motion.div)`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.overlay};
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
z-index: 9999;
|
||||
`;
|
||||
|
||||
const StyledDialogContainer = styled(motion.div)`
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 320px;
|
||||
padding: 2em;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledDialogTitle = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(6)};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const StyledDialogMessage = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(6)};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const StyledDialogButton = styled(Button)`
|
||||
justify-content: center;
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
export type DialogButtonOptions = Omit<
|
||||
React.ComponentProps<typeof Button>,
|
||||
'fullWidth'
|
||||
> & {
|
||||
onClick?: (
|
||||
event: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
|
||||
) => void;
|
||||
role?: 'confirm';
|
||||
};
|
||||
|
||||
export type DialogProps = React.ComponentPropsWithoutRef<typeof motion.div> & {
|
||||
title?: string;
|
||||
message?: string;
|
||||
buttons?: DialogButtonOptions[];
|
||||
allowDismiss?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const Dialog = ({
|
||||
title,
|
||||
message,
|
||||
buttons = [],
|
||||
allowDismiss = true,
|
||||
children,
|
||||
className,
|
||||
onClose,
|
||||
id,
|
||||
}: DialogProps) => {
|
||||
const closeSnackbar = useCallback(() => {
|
||||
onClose && onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const dialogVariants = {
|
||||
open: { opacity: 1 },
|
||||
closed: { opacity: 0 },
|
||||
};
|
||||
|
||||
const containerVariants = {
|
||||
open: { y: 0 },
|
||||
closed: { y: '50vh' },
|
||||
};
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.Enter,
|
||||
(event: KeyboardEvent) => {
|
||||
const confirmButton = buttons.find((button) => button.role === 'confirm');
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (confirmButton) {
|
||||
confirmButton?.onClick?.(event);
|
||||
closeSnackbar();
|
||||
}
|
||||
},
|
||||
DialogHotkeyScope.Dialog,
|
||||
[],
|
||||
);
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.Escape,
|
||||
(event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
closeSnackbar();
|
||||
},
|
||||
DialogHotkeyScope.Dialog,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledDialogOverlay
|
||||
variants={dialogVariants}
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
onClick={(e) => {
|
||||
if (allowDismiss) {
|
||||
e.stopPropagation();
|
||||
closeSnackbar();
|
||||
}
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
<StyledDialogContainer
|
||||
variants={containerVariants}
|
||||
transition={{ damping: 15, stiffness: 100 }}
|
||||
id={id}
|
||||
>
|
||||
{title && <StyledDialogTitle>{title}</StyledDialogTitle>}
|
||||
{message && <StyledDialogMessage>{message}</StyledDialogMessage>}
|
||||
{children}
|
||||
{buttons.map(({ accent, onClick, role, title: key, variant }) => (
|
||||
<StyledDialogButton
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
closeSnackbar();
|
||||
}}
|
||||
fullWidth={true}
|
||||
variant={variant ?? 'secondary'}
|
||||
{...{ accent, key, role }}
|
||||
/>
|
||||
))}
|
||||
</StyledDialogContainer>
|
||||
</StyledDialogOverlay>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
import { useDialogManagerScopedStates } from '../hooks/internal/useDialogManagerScopedStates';
|
||||
import { useDialogManager } from '../hooks/useDialogManager';
|
||||
|
||||
import { Dialog } from './Dialog';
|
||||
import { DialogManagerEffect } from './DialogManagerEffect';
|
||||
|
||||
export const DialogManager = ({ children }: React.PropsWithChildren) => {
|
||||
const { dialogInternal } = useDialogManagerScopedStates();
|
||||
const { closeDialog } = useDialogManager();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogManagerEffect />
|
||||
{children}
|
||||
{dialogInternal.queue.map(({ buttons, children, id, message, title }) => (
|
||||
<Dialog
|
||||
key={id}
|
||||
{...{ title, message, buttons, id, children }}
|
||||
onClose={() => closeDialog(id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
|
||||
import { useDialogManagerScopedStates } from '../hooks/internal/useDialogManagerScopedStates';
|
||||
import { DialogHotkeyScope } from '../types/DialogHotkeyScope';
|
||||
|
||||
export const DialogManagerEffect = () => {
|
||||
const { dialogInternal } = useDialogManagerScopedStates();
|
||||
|
||||
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogInternal.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHotkeyScopeAndMemorizePreviousScope(DialogHotkeyScope.Dialog);
|
||||
}, [dialogInternal.queue, setHotkeyScopeAndMemorizePreviousScope]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
@ -0,0 +1,25 @@
|
||||
import { useRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedStateV2';
|
||||
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
|
||||
|
||||
import { DialogManagerScopeInternalContext } from '../../scopes/scope-internal-context/DialogManagerScopeInternalContext';
|
||||
import { dialogInternalScopedState } from '../../states/dialogInternalScopedState';
|
||||
|
||||
type useDialogManagerScopedStatesProps = {
|
||||
dialogManagerScopeId?: string;
|
||||
};
|
||||
|
||||
export const useDialogManagerScopedStates = (
|
||||
props?: useDialogManagerScopedStatesProps,
|
||||
) => {
|
||||
const scopeId = useAvailableScopeIdOrThrow(
|
||||
DialogManagerScopeInternalContext,
|
||||
props?.dialogManagerScopeId,
|
||||
);
|
||||
|
||||
const [dialogInternal, setDialogInternal] = useRecoilScopedStateV2(
|
||||
dialogInternalScopedState,
|
||||
scopeId,
|
||||
);
|
||||
|
||||
return { dialogInternal, setDialogInternal };
|
||||
};
|
||||
@ -0,0 +1,62 @@
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
|
||||
|
||||
import { DialogManagerScopeInternalContext } from '../scopes/scope-internal-context/DialogManagerScopeInternalContext';
|
||||
import { dialogInternalScopedState } from '../states/dialogInternalScopedState';
|
||||
import { DialogOptions } from '../types/DialogOptions';
|
||||
|
||||
type useDialogManagerProps = {
|
||||
dialogManagerScopeId?: string;
|
||||
};
|
||||
|
||||
export const useDialogManager = (props?: useDialogManagerProps) => {
|
||||
const scopeId = useAvailableScopeIdOrThrow(
|
||||
DialogManagerScopeInternalContext,
|
||||
props?.dialogManagerScopeId,
|
||||
);
|
||||
|
||||
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
|
||||
|
||||
const closeDialog = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(id: string) => {
|
||||
set(dialogInternalScopedState({ scopeId: scopeId }), (prevState) => ({
|
||||
...prevState,
|
||||
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
|
||||
}));
|
||||
goBackToPreviousHotkeyScope();
|
||||
},
|
||||
[goBackToPreviousHotkeyScope, scopeId],
|
||||
);
|
||||
|
||||
const setDialogQueue = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(newValue) =>
|
||||
set(dialogInternalScopedState({ scopeId: scopeId }), (prev) => {
|
||||
if (prev.queue.length >= prev.maxQueue) {
|
||||
return {
|
||||
...prev,
|
||||
queue: [...prev.queue.slice(1), newValue] as DialogOptions[],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
queue: [...prev.queue, newValue] as DialogOptions[],
|
||||
};
|
||||
}),
|
||||
[scopeId],
|
||||
);
|
||||
|
||||
const enqueueDialog = (options?: Omit<DialogOptions, 'id'>) => {
|
||||
setDialogQueue({
|
||||
id: v4(),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
return { closeDialog, enqueueDialog };
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { DialogManagerScopeInternalContext } from './scope-internal-context/DialogManagerScopeInternalContext';
|
||||
|
||||
type DialogManagerScopeProps = {
|
||||
children: ReactNode;
|
||||
dialogManagerScopeId: string;
|
||||
};
|
||||
|
||||
export const DialogManagerScope = ({
|
||||
children,
|
||||
dialogManagerScopeId,
|
||||
}: DialogManagerScopeProps) => {
|
||||
return (
|
||||
<DialogManagerScopeInternalContext.Provider
|
||||
value={{
|
||||
scopeId: dialogManagerScopeId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DialogManagerScopeInternalContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
|
||||
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
|
||||
|
||||
type DialogManagerScopeInternalContextProps = ScopedStateKey;
|
||||
|
||||
export const DialogManagerScopeInternalContext =
|
||||
createScopeInternalContext<DialogManagerScopeInternalContextProps>();
|
||||
@ -0,0 +1,16 @@
|
||||
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
|
||||
|
||||
import { DialogOptions } from '../types/DialogOptions';
|
||||
|
||||
type DialogState = {
|
||||
maxQueue: number;
|
||||
queue: DialogOptions[];
|
||||
};
|
||||
|
||||
export const dialogInternalScopedState = createScopedState<DialogState>({
|
||||
key: 'dialog/internal-state',
|
||||
defaultValue: {
|
||||
maxQueue: 2,
|
||||
queue: [],
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
export enum DialogHotkeyScope {
|
||||
Dialog = 'dialog',
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DialogProps } from '../components/Dialog';
|
||||
|
||||
export type DialogOptions = DialogProps & {
|
||||
id: string;
|
||||
};
|
||||
@ -0,0 +1,71 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { motion, useAnimation } from 'framer-motion';
|
||||
|
||||
interface CircularProgressBarProps {
|
||||
size?: number;
|
||||
barWidth?: number;
|
||||
barColor?: string;
|
||||
}
|
||||
|
||||
export const CircularProgressBar = ({
|
||||
size = 50,
|
||||
barWidth = 5,
|
||||
barColor = 'currentColor',
|
||||
}: CircularProgressBarProps) => {
|
||||
const controls = useAnimation();
|
||||
|
||||
const circumference = useMemo(
|
||||
() => 2 * Math.PI * (size / 2 - barWidth),
|
||||
[size, barWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const animateIndeterminate = async () => {
|
||||
const baseSegment = Math.max(5, circumference / 10); // Adjusting for smaller values
|
||||
|
||||
// Adjusted sequence based on baseSegment
|
||||
const dashSequences = [
|
||||
`${baseSegment} ${circumference - baseSegment}`,
|
||||
`${baseSegment * 2} ${circumference - baseSegment * 2}`,
|
||||
`${baseSegment * 3} ${circumference - baseSegment * 3}`,
|
||||
`${baseSegment * 2} ${circumference - baseSegment * 2}`,
|
||||
`${baseSegment} ${circumference - baseSegment}`,
|
||||
];
|
||||
|
||||
await controls.start({
|
||||
strokeDasharray: dashSequences,
|
||||
rotate: [0, 720],
|
||||
transition: {
|
||||
strokeDasharray: {
|
||||
duration: 2,
|
||||
ease: 'linear',
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop',
|
||||
},
|
||||
rotate: {
|
||||
duration: 2,
|
||||
ease: 'linear',
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
animateIndeterminate();
|
||||
}, [circumference, controls]);
|
||||
|
||||
return (
|
||||
<motion.svg width={size} height={size} animate={controls}>
|
||||
<motion.circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={size / 2 - barWidth}
|
||||
fill="none"
|
||||
stroke={barColor}
|
||||
strokeWidth={barWidth}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,110 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { AnimationControls, motion, useAnimation } from 'framer-motion';
|
||||
|
||||
export type ProgressBarProps = {
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
easing?: string;
|
||||
barHeight?: number;
|
||||
barColor?: string;
|
||||
autoStart?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type StyledBarProps = {
|
||||
barHeight?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type ProgressBarControls = AnimationControls & {
|
||||
start: () => Promise<any>;
|
||||
pause: () => Promise<any>;
|
||||
};
|
||||
|
||||
const StyledBar = styled.div<StyledBarProps>`
|
||||
height: ${({ barHeight }) => barHeight}px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledBarFilling = styled(motion.div)`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const ProgressBar = forwardRef<ProgressBarControls, ProgressBarProps>(
|
||||
(
|
||||
{
|
||||
duration = 3,
|
||||
delay = 0,
|
||||
easing = 'easeInOut',
|
||||
barHeight = 24,
|
||||
barColor,
|
||||
autoStart = true,
|
||||
className,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const controls = useAnimation();
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
const startTimestamp = useRef<number>(0);
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
const remainingTime = useRef<number>(duration);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
startTimestamp.current = Date.now();
|
||||
return controls.start({
|
||||
scaleX: 0,
|
||||
transition: {
|
||||
duration: remainingTime.current / 1000, // convert ms to s for framer-motion
|
||||
delay: delay / 1000, // likewise
|
||||
ease: easing,
|
||||
},
|
||||
});
|
||||
}, [controls, delay, easing]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
...controls,
|
||||
start: async () => {
|
||||
return start();
|
||||
},
|
||||
pause: async () => {
|
||||
const elapsed = Date.now() - startTimestamp.current;
|
||||
|
||||
remainingTime.current = remainingTime.current - elapsed;
|
||||
return controls.stop();
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (autoStart) {
|
||||
start();
|
||||
}
|
||||
}, [controls, delay, duration, easing, autoStart, start]);
|
||||
|
||||
return (
|
||||
<StyledBar className={className} barHeight={barHeight}>
|
||||
<StyledBarFilling
|
||||
style={{
|
||||
originX: 0,
|
||||
// Seems like custom props are not well handled by react when used with framer-motion and emotion styled
|
||||
backgroundColor: barColor ?? theme.color.gray80,
|
||||
}}
|
||||
initial={{ scaleX: 1 }}
|
||||
animate={controls}
|
||||
exit={{ scaleX: 0 }}
|
||||
/>
|
||||
</StyledBar>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,56 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import { CircularProgressBar } from '../CircularProgressBar';
|
||||
|
||||
const meta: Meta<typeof CircularProgressBar> = {
|
||||
title: 'UI/Feedback/CircularProgressBar/CircularProgressBar',
|
||||
component: CircularProgressBar,
|
||||
args: {
|
||||
size: 50,
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disableSnapshot: true },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof CircularProgressBar>;
|
||||
|
||||
export const Default: Story = {
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export const Catalog: CatalogStory<Story, typeof CircularProgressBar> = {
|
||||
argTypes: {},
|
||||
parameters: {
|
||||
catalog: {
|
||||
dimensions: [
|
||||
{
|
||||
name: 'barColor',
|
||||
values: [undefined, 'red'],
|
||||
props: (barColor: string) => ({ barColor }),
|
||||
labels: (color: string) => `Segment Color: ${color ?? 'default'}`,
|
||||
},
|
||||
{
|
||||
name: 'barWidth',
|
||||
values: [undefined, 5, 10],
|
||||
props: (barWidth: number) => ({ barWidth }),
|
||||
labels: (width: number) =>
|
||||
`Stroke Width: ${width ? width + ' px' : 'default'}`,
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
values: [undefined, 80, 30],
|
||||
props: (size: number) => ({ size }),
|
||||
labels: (size: number) => `Size: ${size ? size + ' px' : 'default'}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [CatalogDecorator],
|
||||
};
|
||||
@ -0,0 +1,63 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import { ProgressBar } from '../ProgressBar';
|
||||
|
||||
const meta: Meta<typeof ProgressBar> = {
|
||||
title: 'UI/Feedback/ProgressBar/ProgressBar',
|
||||
component: ProgressBar,
|
||||
args: {
|
||||
duration: 10000,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ProgressBar>;
|
||||
const args = {};
|
||||
const defaultArgTypes = {
|
||||
control: false,
|
||||
};
|
||||
export const Default: Story = {
|
||||
args,
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export const Catalog: CatalogStory<Story, typeof ProgressBar> = {
|
||||
args: {
|
||||
...args,
|
||||
},
|
||||
argTypes: {
|
||||
barHeight: defaultArgTypes,
|
||||
barColor: defaultArgTypes,
|
||||
autoStart: defaultArgTypes,
|
||||
},
|
||||
parameters: {
|
||||
catalog: {
|
||||
dimensions: [
|
||||
{
|
||||
name: 'animation',
|
||||
values: [true, false],
|
||||
props: (autoStart: string) => ({ autoStart: Boolean(autoStart) }),
|
||||
labels: (autoStart: string) => `AutoStart: ${autoStart}`,
|
||||
},
|
||||
{
|
||||
name: 'colors',
|
||||
values: [undefined, 'blue'],
|
||||
props: (barColor: string) => ({ barColor }),
|
||||
labels: (color: string) => `Color: ${color ?? 'default'}`,
|
||||
},
|
||||
{
|
||||
name: 'sizes',
|
||||
values: [undefined, 10],
|
||||
props: (barHeight: number) => ({ barHeight }),
|
||||
labels: (size: number) => `Size: ${size ? size + ' px' : 'default'}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [CatalogDecorator],
|
||||
};
|
||||
@ -0,0 +1,183 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconAlertTriangle, IconX } from '@/ui/display/icon';
|
||||
import {
|
||||
ProgressBar,
|
||||
ProgressBarControls,
|
||||
} from '@/ui/feedback/progress-bar/components/ProgressBar';
|
||||
import { rgba } from '@/ui/theme/constants/colors';
|
||||
|
||||
import { usePausableTimeout } from '../hooks/usePausableTimeout';
|
||||
|
||||
const StyledMotionContainer = styled.div<Pick<SnackBarProps, 'variant'>>`
|
||||
align-items: center;
|
||||
background-color: ${({ theme, variant }) => {
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
return theme.snackBar.error.background;
|
||||
case 'success':
|
||||
return theme.snackBar.success.background;
|
||||
case 'info':
|
||||
default:
|
||||
return theme.color.gray80;
|
||||
}
|
||||
}};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
box-shadow: ${({ theme }) => theme.boxShadow.strong};
|
||||
color: ${({ theme, variant }) => {
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
return theme.snackBar.error.color;
|
||||
case 'success':
|
||||
return theme.snackBar.success.color;
|
||||
case 'info':
|
||||
default:
|
||||
return theme.grayScale.gray0;
|
||||
}
|
||||
}};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
display: flex;
|
||||
margin-right: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledProgressBarContainer = styled.div`
|
||||
height: 5px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
const StyledCloseButton = styled.button<Pick<SnackBarProps, 'variant'>>`
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: ${({ theme, variant }) => {
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
return theme.color.red20;
|
||||
case 'success':
|
||||
return theme.color.turquoise20;
|
||||
case 'info':
|
||||
default:
|
||||
return theme.grayScale.gray0;
|
||||
}
|
||||
}};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 24px;
|
||||
justify-content: center;
|
||||
margin-left: ${({ theme }) => theme.spacing(6)};
|
||||
padding-left: ${({ theme }) => theme.spacing(1)};
|
||||
padding-right: ${({ theme }) => theme.spacing(1)};
|
||||
width: 24px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => rgba(theme.grayScale.gray0, 0.1)};
|
||||
}
|
||||
`;
|
||||
|
||||
export type SnackbarVariant = 'info' | 'error' | 'success';
|
||||
|
||||
export interface SnackBarProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
role?: 'alert' | 'status';
|
||||
icon?: React.ReactNode;
|
||||
message?: string;
|
||||
allowDismiss?: boolean;
|
||||
duration?: number;
|
||||
variant?: SnackbarVariant;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const SnackBar = ({
|
||||
role = 'status',
|
||||
icon: iconComponent,
|
||||
message,
|
||||
allowDismiss = true,
|
||||
duration = 6000,
|
||||
variant = 'info',
|
||||
children,
|
||||
onClose,
|
||||
id,
|
||||
title,
|
||||
className,
|
||||
}: SnackBarProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
const progressBarRef = useRef<ProgressBarControls | null>(null);
|
||||
|
||||
const closeSnackbar = useCallback(() => {
|
||||
onClose && onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const { pauseTimeout, resumeTimeout } = usePausableTimeout(
|
||||
closeSnackbar,
|
||||
duration,
|
||||
);
|
||||
|
||||
const icon = useMemo(() => {
|
||||
if (iconComponent) {
|
||||
return iconComponent;
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
return (
|
||||
<IconAlertTriangle aria-label="Error" size={theme.icon.size.md} />
|
||||
);
|
||||
case 'success':
|
||||
case 'info':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [iconComponent, theme.icon.size.md, variant]);
|
||||
|
||||
const onMouseEnter = () => {
|
||||
progressBarRef.current?.pause();
|
||||
pauseTimeout();
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
progressBarRef.current?.start();
|
||||
resumeTimeout();
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledMotionContainer
|
||||
className={className}
|
||||
aria-live={role === 'alert' ? 'assertive' : 'polite'}
|
||||
{...{ id, onMouseEnter, onMouseLeave, role, title, variant }}
|
||||
>
|
||||
<StyledProgressBarContainer>
|
||||
<ProgressBar
|
||||
ref={progressBarRef}
|
||||
barHeight={5}
|
||||
barColor={rgba(theme.grayScale.gray0, 0.3)}
|
||||
duration={duration}
|
||||
/>
|
||||
</StyledProgressBarContainer>
|
||||
{icon && <StyledIconContainer>{icon}</StyledIconContainer>}
|
||||
{children ? children : message}
|
||||
{allowDismiss && (
|
||||
<StyledCloseButton variant={variant} onClick={closeSnackbar}>
|
||||
<IconX aria-label="Close" size={theme.icon.size.md} />
|
||||
</StyledCloseButton>
|
||||
)}
|
||||
</StyledMotionContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,83 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
import { useSnackBarManagerScopedStates } from '@/ui/feedback/snack-bar-manager/hooks/internal/useSnackBarManagerScopedStates';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
|
||||
import { SnackBar } from './SnackBar';
|
||||
const StyledSnackBarContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 99999999;
|
||||
`;
|
||||
|
||||
const StyledSnackBarMotionContainer = styled(motion.div)`
|
||||
margin-right: ${({ theme }) => theme.spacing(3)};
|
||||
margin-top: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
const variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: -40,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -40,
|
||||
},
|
||||
};
|
||||
|
||||
const reducedVariants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: -40,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -40,
|
||||
},
|
||||
};
|
||||
|
||||
export const SnackBarProvider = ({ children }: React.PropsWithChildren) => {
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
const { snackBarInternal } = useSnackBarManagerScopedStates();
|
||||
const { handleSnackBarClose } = useSnackBar();
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<StyledSnackBarContainer>
|
||||
{snackBarInternal.queue.map(
|
||||
({ duration, icon, id, message, title, variant }) => (
|
||||
<StyledSnackBarMotionContainer
|
||||
key={id}
|
||||
variants={reducedMotion ? reducedVariants : variants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.5 }}
|
||||
layout
|
||||
>
|
||||
<SnackBar
|
||||
{...{ duration, icon, message, title, variant }}
|
||||
onClose={() => handleSnackBarClose(id)}
|
||||
/>
|
||||
</StyledSnackBarMotionContainer>
|
||||
),
|
||||
)}
|
||||
</StyledSnackBarContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext';
|
||||
import { snackBarInternalScopedState } from '@/ui/feedback/snack-bar-manager/states/snackBarInternalScopedState';
|
||||
import { useRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedStateV2';
|
||||
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
|
||||
|
||||
type useSnackBarManagerScopedStatesProps = {
|
||||
snackBarManagerScopeId?: string;
|
||||
};
|
||||
|
||||
export const useSnackBarManagerScopedStates = (
|
||||
props?: useSnackBarManagerScopedStatesProps,
|
||||
) => {
|
||||
const scopeId = useAvailableScopeIdOrThrow(
|
||||
SnackBarManagerScopeInternalContext,
|
||||
props?.snackBarManagerScopeId,
|
||||
);
|
||||
|
||||
const [snackBarInternal, setSnackBarInternal] = useRecoilScopedStateV2(
|
||||
snackBarInternalScopedState,
|
||||
scopeId,
|
||||
);
|
||||
|
||||
return { snackBarInternal, setSnackBarInternal };
|
||||
};
|
||||
@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export const usePausableTimeout = (callback: () => void, delay: number) => {
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
const savedCallback = useRef<() => void>(callback);
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
const remainingTime = useRef<number>(delay);
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
const startTime = useRef<number>(Date.now());
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const tick = () => {
|
||||
if (savedCallback.current) {
|
||||
savedCallback.current();
|
||||
}
|
||||
};
|
||||
|
||||
const startTimeout = useCallback(() => {
|
||||
startTime.current = Date.now();
|
||||
timeoutId.current = setTimeout(tick, remainingTime.current);
|
||||
}, []);
|
||||
|
||||
// Remember the latest callback
|
||||
useEffect(() => {
|
||||
savedCallback.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
// Set up the timeout loop
|
||||
useEffect(() => {
|
||||
if (delay !== null) {
|
||||
startTimeout();
|
||||
return () => {
|
||||
if (timeoutId.current) {
|
||||
clearTimeout(timeoutId.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [delay, startTimeout]);
|
||||
|
||||
const pauseTimeout = () => {
|
||||
if (timeoutId.current) {
|
||||
clearTimeout(timeoutId.current);
|
||||
}
|
||||
const elapsedTime = Date.now() - startTime.current;
|
||||
remainingTime.current = remainingTime.current - elapsedTime;
|
||||
};
|
||||
|
||||
const resumeTimeout = () => {
|
||||
startTimeout();
|
||||
};
|
||||
|
||||
return { pauseTimeout, resumeTimeout };
|
||||
};
|
||||
@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext';
|
||||
import {
|
||||
snackBarInternalScopedState,
|
||||
SnackBarOptions,
|
||||
} from '@/ui/feedback/snack-bar-manager/states/snackBarInternalScopedState';
|
||||
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
|
||||
|
||||
export const useSnackBar = () => {
|
||||
const scopeId = useAvailableScopeIdOrThrow(
|
||||
SnackBarManagerScopeInternalContext,
|
||||
);
|
||||
|
||||
const handleSnackBarClose = useRecoilCallback(({ set }) => (id: string) => {
|
||||
set(snackBarInternalScopedState({ scopeId }), (prevState) => ({
|
||||
...prevState,
|
||||
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
|
||||
}));
|
||||
});
|
||||
|
||||
const setSnackBarQueue = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(newValue) =>
|
||||
set(snackBarInternalScopedState({ scopeId }), (prev) => {
|
||||
if (prev.queue.length >= prev.maxQueue) {
|
||||
return {
|
||||
...prev,
|
||||
queue: [...prev.queue.slice(1), newValue] as SnackBarOptions[],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
queue: [...prev.queue, newValue] as SnackBarOptions[],
|
||||
};
|
||||
}),
|
||||
[scopeId],
|
||||
);
|
||||
|
||||
const enqueueSnackBar = useCallback(
|
||||
(message: string, options?: Omit<SnackBarOptions, 'message' | 'id'>) => {
|
||||
setSnackBarQueue({
|
||||
id: uuidv4(),
|
||||
message,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
[setSnackBarQueue],
|
||||
);
|
||||
|
||||
return { handleSnackBarClose, enqueueSnackBar };
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { SnackBarManagerScopeInternalContext } from './scope-internal-context/SnackBarManagerScopeInternalContext';
|
||||
|
||||
type SnackBarProviderScopeProps = {
|
||||
children: ReactNode;
|
||||
snackBarManagerScopeId: string;
|
||||
};
|
||||
|
||||
export const SnackBarProviderScope = ({
|
||||
children,
|
||||
snackBarManagerScopeId,
|
||||
}: SnackBarProviderScopeProps) => {
|
||||
return (
|
||||
<SnackBarManagerScopeInternalContext.Provider
|
||||
value={{
|
||||
scopeId: snackBarManagerScopeId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SnackBarManagerScopeInternalContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
|
||||
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
|
||||
|
||||
type SnackBarManagerScopeInternalContextProps = ScopedStateKey;
|
||||
|
||||
export const SnackBarManagerScopeInternalContext =
|
||||
createScopeInternalContext<SnackBarManagerScopeInternalContextProps>();
|
||||
@ -0,0 +1,20 @@
|
||||
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
|
||||
|
||||
import { SnackBarProps } from '../components/SnackBar';
|
||||
|
||||
export type SnackBarOptions = SnackBarProps & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
type SnackBarState = {
|
||||
maxQueue: number;
|
||||
queue: SnackBarOptions[];
|
||||
};
|
||||
|
||||
export const snackBarInternalScopedState = createScopedState<SnackBarState>({
|
||||
key: 'snackBarState',
|
||||
defaultValue: {
|
||||
maxQueue: 3,
|
||||
queue: [],
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,10 @@
|
||||
import { EllipsisDisplay } from './EllipsisDisplay';
|
||||
|
||||
type CurrencyDisplayProps = {
|
||||
amount?: number | null;
|
||||
};
|
||||
|
||||
// TODO: convert currencyCode to currency symbol
|
||||
export const CurrencyDisplay = ({ amount }: CurrencyDisplayProps) => {
|
||||
return <EllipsisDisplay>{amount}</EllipsisDisplay>;
|
||||
};
|
||||
@ -0,0 +1,11 @@
|
||||
import { formatToHumanReadableDate } from '~/utils';
|
||||
|
||||
import { EllipsisDisplay } from './EllipsisDisplay';
|
||||
|
||||
type DateDisplayProps = {
|
||||
value: Date | string | null | undefined;
|
||||
};
|
||||
|
||||
export const DateDisplay = ({ value }: DateDisplayProps) => (
|
||||
<EllipsisDisplay>{value && formatToHumanReadableDate(value)}</EllipsisDisplay>
|
||||
);
|
||||
@ -0,0 +1,3 @@
|
||||
import { TextDisplay } from './TextDisplay';
|
||||
|
||||
export const DoubleTextDisplay = TextDisplay;
|
||||
@ -0,0 +1,10 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledEllipsisDisplay = styled.div`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export { StyledEllipsisDisplay as EllipsisDisplay };
|
||||
@ -0,0 +1,31 @@
|
||||
import { MouseEvent } from 'react';
|
||||
|
||||
import { ContactLink } from '@/ui/navigation/link/components/ContactLink';
|
||||
|
||||
import { EllipsisDisplay } from './EllipsisDisplay';
|
||||
|
||||
const validateEmail = (email: string) => {
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailPattern.test(email.trim());
|
||||
};
|
||||
|
||||
type EmailDisplayProps = {
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
export const EmailDisplay = ({ value }: EmailDisplayProps) => (
|
||||
<EllipsisDisplay>
|
||||
{value && validateEmail(value) ? (
|
||||
<ContactLink
|
||||
href={`mailto:${value}`}
|
||||
onClick={(event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</ContactLink>
|
||||
) : (
|
||||
<ContactLink href="#">{value}</ContactLink>
|
||||
)}
|
||||
</EllipsisDisplay>
|
||||
);
|
||||
@ -0,0 +1,73 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { FieldLinkValue } from '@/object-record/field/types/FieldMetadata';
|
||||
import { RoundedLink } from '@/ui/navigation/link/components/RoundedLink';
|
||||
import {
|
||||
LinkType,
|
||||
SocialLink,
|
||||
} from '@/ui/navigation/link/components/SocialLink';
|
||||
|
||||
import { EllipsisDisplay } from './EllipsisDisplay';
|
||||
|
||||
const StyledRawLink = styled(RoundedLink)`
|
||||
overflow: hidden;
|
||||
|
||||
a {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
type LinkDisplayProps = {
|
||||
value?: FieldLinkValue;
|
||||
};
|
||||
|
||||
const checkUrlType = (url: string) => {
|
||||
if (
|
||||
/^(http|https):\/\/(?:www\.)?linkedin.com(\w+:{0,1}\w*@)?(\S+)(:([0-9])+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/.test(
|
||||
url,
|
||||
)
|
||||
) {
|
||||
return LinkType.LinkedIn;
|
||||
}
|
||||
if (url.match(/^((http|https):\/\/)?(?:www\.)?twitter\.com\/(\w+)?/i)) {
|
||||
return LinkType.Twitter;
|
||||
}
|
||||
|
||||
return LinkType.Url;
|
||||
};
|
||||
|
||||
export const LinkDisplay = ({ value }: LinkDisplayProps) => {
|
||||
const handleClick = (event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const absoluteUrl = value?.url
|
||||
? value.url.startsWith('http')
|
||||
? value.url
|
||||
: 'https://' + value.url
|
||||
: '';
|
||||
|
||||
const displayedValue = value?.label || value?.url || '';
|
||||
|
||||
const type = checkUrlType(absoluteUrl);
|
||||
|
||||
if (type === LinkType.LinkedIn || type === LinkType.Twitter) {
|
||||
return (
|
||||
<EllipsisDisplay>
|
||||
<SocialLink href={absoluteUrl} onClick={handleClick} type={type}>
|
||||
{displayedValue}
|
||||
</SocialLink>
|
||||
</EllipsisDisplay>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EllipsisDisplay>
|
||||
<StyledRawLink href={absoluteUrl} onClick={handleClick}>
|
||||
{displayedValue}
|
||||
</StyledRawLink>
|
||||
</EllipsisDisplay>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,11 @@
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
|
||||
import { EllipsisDisplay } from './EllipsisDisplay';
|
||||
|
||||
type MoneyDisplayProps = {
|
||||
value: number | null;
|
||||
};
|
||||
|
||||
export const MoneyDisplay = ({ value }: MoneyDisplayProps) => (
|
||||
<EllipsisDisplay>{value ? `$${formatNumber(value)}` : ''}</EllipsisDisplay>
|
||||
);
|
||||
@ -0,0 +1,11 @@
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
|
||||
import { EllipsisDisplay } from './EllipsisDisplay';
|
||||
|
||||
type NumberDisplayProps = {
|
||||
value: string | number | null;
|
||||
};
|
||||
|
||||
export const NumberDisplay = ({ value }: NumberDisplayProps) => (
|
||||
<EllipsisDisplay>{value && formatNumber(Number(value))}</EllipsisDisplay>
|
||||
);
|
||||
@ -0,0 +1,27 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js';
|
||||
|
||||
import { ContactLink } from '@/ui/navigation/link/components/ContactLink';
|
||||
|
||||
import { EllipsisDisplay } from './EllipsisDisplay';
|
||||
|
||||
type PhoneDisplayProps = {
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
export const PhoneDisplay = ({ value }: PhoneDisplayProps) => (
|
||||
<EllipsisDisplay>
|
||||
{value && isValidPhoneNumber(value) ? (
|
||||
<ContactLink
|
||||
href={parsePhoneNumber(value, 'FR')?.getURI()}
|
||||
onClick={(event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{parsePhoneNumber(value, 'FR')?.formatInternational() || value}
|
||||
</ContactLink>
|
||||
) : (
|
||||
<ContactLink href="#">{value}</ContactLink>
|
||||
)}
|
||||
</EllipsisDisplay>
|
||||
);
|
||||
@ -0,0 +1,9 @@
|
||||
import { EllipsisDisplay } from './EllipsisDisplay';
|
||||
|
||||
type TextDisplayProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const TextDisplay = ({ text }: TextDisplayProps) => (
|
||||
<EllipsisDisplay>{text}</EllipsisDisplay>
|
||||
);
|
||||
@ -0,0 +1,72 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { RoundedLink } from '@/ui/navigation/link/components/RoundedLink';
|
||||
import {
|
||||
LinkType,
|
||||
SocialLink,
|
||||
} from '@/ui/navigation/link/components/SocialLink';
|
||||
|
||||
import { EllipsisDisplay } from './EllipsisDisplay';
|
||||
|
||||
const StyledRawLink = styled(RoundedLink)`
|
||||
overflow: hidden;
|
||||
|
||||
a {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
type URLDisplayProps = {
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
const checkUrlType = (url: string) => {
|
||||
if (
|
||||
/^(http|https):\/\/(?:www\.)?linkedin.com(\w+:{0,1}\w*@)?(\S+)(:([0-9])+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/.test(
|
||||
url,
|
||||
)
|
||||
) {
|
||||
return LinkType.LinkedIn;
|
||||
}
|
||||
if (url.match(/^((http|https):\/\/)?(?:www\.)?twitter\.com\/(\w+)?/i)) {
|
||||
return LinkType.Twitter;
|
||||
}
|
||||
|
||||
return LinkType.Url;
|
||||
};
|
||||
|
||||
export const URLDisplay = ({ value }: URLDisplayProps) => {
|
||||
const handleClick = (event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const absoluteUrl = value
|
||||
? value.startsWith('http')
|
||||
? value
|
||||
: 'https://' + value
|
||||
: '';
|
||||
|
||||
const displayedValue = value ?? '';
|
||||
|
||||
const type = checkUrlType(absoluteUrl);
|
||||
|
||||
if (type === LinkType.LinkedIn || type === LinkType.Twitter) {
|
||||
return (
|
||||
<EllipsisDisplay>
|
||||
<SocialLink href={absoluteUrl} onClick={handleClick} type={type}>
|
||||
{displayedValue}
|
||||
</SocialLink>
|
||||
</EllipsisDisplay>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EllipsisDisplay>
|
||||
<StyledRawLink href={absoluteUrl} onClick={handleClick}>
|
||||
{displayedValue}
|
||||
</StyledRawLink>
|
||||
</EllipsisDisplay>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,20 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
|
||||
|
||||
import { PhoneDisplay } from '../PhoneDisplay'; // Adjust the import path as needed
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'UI/Input/PhoneInputDisplay/PhoneInputDisplay',
|
||||
component: PhoneDisplay,
|
||||
decorators: [ComponentWithRouterDecorator],
|
||||
args: {
|
||||
value: '+33788901234',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof PhoneDisplay>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconCheck, IconX } from '@/ui/display/icon';
|
||||
|
||||
const StyledEditableBooleanFieldContainer = styled.div`
|
||||
align-items: center;
|
||||
cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')};
|
||||
display: flex;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledEditableBooleanFieldValue = styled.div`
|
||||
margin-left: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
type BooleanInputProps = {
|
||||
value: boolean;
|
||||
onToggle?: (newValue: boolean) => void;
|
||||
readonly?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export const BooleanInput = ({
|
||||
value,
|
||||
onToggle,
|
||||
readonly,
|
||||
testId,
|
||||
}: BooleanInputProps) => {
|
||||
const [internalValue, setInternalValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleClick = () => {
|
||||
setInternalValue(!internalValue);
|
||||
onToggle?.(!internalValue);
|
||||
};
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledEditableBooleanFieldContainer
|
||||
onClick={readonly ? undefined : handleClick}
|
||||
data-testid={testId}
|
||||
>
|
||||
{internalValue ? (
|
||||
<IconCheck size={theme.icon.size.sm} />
|
||||
) : (
|
||||
<IconX size={theme.icon.size.sm} />
|
||||
)}
|
||||
<StyledEditableBooleanFieldValue>
|
||||
{internalValue ? 'True' : 'False'}
|
||||
</StyledEditableBooleanFieldValue>
|
||||
</StyledEditableBooleanFieldContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { flip, offset, useFloating } from '@floating-ui/react';
|
||||
|
||||
import { DateDisplay } from '@/ui/field/display/components/DateDisplay';
|
||||
import { InternalDatePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker';
|
||||
import { Nullable } from '~/types/Nullable';
|
||||
|
||||
import { useRegisterInputEvents } from '../../../../object-record/field/meta-types/input/hooks/useRegisterInputEvents';
|
||||
|
||||
const StyledCalendarContainer = styled.div`
|
||||
background: ${({ theme }) => theme.background.secondary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
box-shadow: ${({ theme }) => theme.boxShadow.strong};
|
||||
|
||||
margin-top: 1px;
|
||||
|
||||
position: absolute;
|
||||
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
padding: ${({ theme }) => theme.spacing(0)} ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export type DateInputProps = {
|
||||
value: Nullable<Date>;
|
||||
onEnter: (newDate: Nullable<Date>) => void;
|
||||
onEscape: (newDate: Nullable<Date>) => void;
|
||||
onClickOutside: (
|
||||
event: MouseEvent | TouchEvent,
|
||||
newDate: Nullable<Date>,
|
||||
) => void;
|
||||
hotkeyScope: string;
|
||||
};
|
||||
|
||||
export const DateInput = ({
|
||||
value,
|
||||
hotkeyScope,
|
||||
onEnter,
|
||||
onEscape,
|
||||
onClickOutside,
|
||||
}: DateInputProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const [internalValue, setInternalValue] = useState(value);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
placement: 'bottom-start',
|
||||
middleware: [
|
||||
flip(),
|
||||
offset({
|
||||
mainAxis: theme.spacingMultiplicator * 2,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const handleChange = (newDate: Date) => {
|
||||
setInternalValue(newDate);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value);
|
||||
}, [value]);
|
||||
|
||||
useRegisterInputEvents({
|
||||
inputRef: wrapperRef,
|
||||
inputValue: internalValue,
|
||||
onEnter,
|
||||
onEscape,
|
||||
onClickOutside,
|
||||
hotkeyScope,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
<div ref={refs.setReference}>
|
||||
<StyledInputContainer>
|
||||
<DateDisplay value={internalValue ?? new Date()} />
|
||||
</StyledInputContainer>
|
||||
</div>
|
||||
<div ref={refs.setFloating} style={floatingStyles}>
|
||||
<StyledCalendarContainer>
|
||||
<InternalDatePicker
|
||||
date={internalValue ?? new Date()}
|
||||
onChange={handleChange}
|
||||
onMouseSelect={(newDate: Date) => {
|
||||
onEnter(newDate);
|
||||
}}
|
||||
/>
|
||||
</StyledCalendarContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,171 @@
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { FieldDoubleText } from '@/object-record/field/types/FieldDoubleText';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
import { StyledInput } from './TextInput';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
input {
|
||||
width: ${({ theme }) => theme.spacing(24)};
|
||||
}
|
||||
|
||||
& > input:last-child {
|
||||
border-left: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||
}
|
||||
`;
|
||||
|
||||
type DoubleTextInputProps = {
|
||||
firstValue: string;
|
||||
secondValue: string;
|
||||
firstValuePlaceholder: string;
|
||||
secondValuePlaceholder: string;
|
||||
hotkeyScope: string;
|
||||
onEnter: (newDoubleTextValue: FieldDoubleText) => void;
|
||||
onEscape: (newDoubleTextValue: FieldDoubleText) => void;
|
||||
onTab?: (newDoubleTextValue: FieldDoubleText) => void;
|
||||
onShiftTab?: (newDoubleTextValue: FieldDoubleText) => void;
|
||||
onClickOutside: (
|
||||
event: MouseEvent | TouchEvent,
|
||||
newDoubleTextValue: FieldDoubleText,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const DoubleTextInput = ({
|
||||
firstValue,
|
||||
secondValue,
|
||||
firstValuePlaceholder,
|
||||
secondValuePlaceholder,
|
||||
hotkeyScope,
|
||||
onClickOutside,
|
||||
onEnter,
|
||||
onEscape,
|
||||
onShiftTab,
|
||||
onTab,
|
||||
}: DoubleTextInputProps) => {
|
||||
const [firstInternalValue, setFirstInternalValue] = useState(firstValue);
|
||||
const [secondInternalValue, setSecondInternalValue] = useState(secondValue);
|
||||
|
||||
const firstValueInputRef = useRef<HTMLInputElement>(null);
|
||||
const secondValueInputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFirstInternalValue(firstValue);
|
||||
setSecondInternalValue(secondValue);
|
||||
}, [firstValue, secondValue]);
|
||||
|
||||
const handleChange = (
|
||||
newFirstValue: string,
|
||||
newSecondValue: string,
|
||||
): void => {
|
||||
setFirstInternalValue(newFirstValue);
|
||||
setSecondInternalValue(newSecondValue);
|
||||
};
|
||||
|
||||
const [focusPosition, setFocusPosition] = useState<'left' | 'right'>('left');
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.Enter,
|
||||
() => {
|
||||
onEnter({
|
||||
firstValue: firstInternalValue,
|
||||
secondValue: secondInternalValue,
|
||||
});
|
||||
},
|
||||
hotkeyScope,
|
||||
[onEnter, firstInternalValue, secondInternalValue],
|
||||
);
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.Escape,
|
||||
() => {
|
||||
onEscape({
|
||||
firstValue: firstInternalValue,
|
||||
secondValue: secondInternalValue,
|
||||
});
|
||||
},
|
||||
hotkeyScope,
|
||||
[onEscape, firstInternalValue, secondInternalValue],
|
||||
);
|
||||
|
||||
useScopedHotkeys(
|
||||
'tab',
|
||||
() => {
|
||||
if (focusPosition === 'left') {
|
||||
setFocusPosition('right');
|
||||
secondValueInputRef.current?.focus();
|
||||
} else {
|
||||
onTab?.({
|
||||
firstValue: firstInternalValue,
|
||||
secondValue: secondInternalValue,
|
||||
});
|
||||
}
|
||||
},
|
||||
hotkeyScope,
|
||||
[onTab, firstInternalValue, secondInternalValue, focusPosition],
|
||||
);
|
||||
|
||||
useScopedHotkeys(
|
||||
'shift+tab',
|
||||
() => {
|
||||
if (focusPosition === 'right') {
|
||||
setFocusPosition('left');
|
||||
firstValueInputRef.current?.focus();
|
||||
} else {
|
||||
onShiftTab?.({
|
||||
firstValue: firstInternalValue,
|
||||
secondValue: secondInternalValue,
|
||||
});
|
||||
}
|
||||
},
|
||||
hotkeyScope,
|
||||
[onShiftTab, firstInternalValue, secondInternalValue, focusPosition],
|
||||
);
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [containerRef],
|
||||
callback: (event) => {
|
||||
onClickOutside?.(event, {
|
||||
firstValue: firstInternalValue,
|
||||
secondValue: secondInternalValue,
|
||||
});
|
||||
},
|
||||
enabled: isDefined(onClickOutside),
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledContainer ref={containerRef}>
|
||||
<StyledInput
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
onFocus={() => setFocusPosition('left')}
|
||||
ref={firstValueInputRef}
|
||||
placeholder={firstValuePlaceholder}
|
||||
value={firstInternalValue}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
handleChange(event.target.value, secondInternalValue);
|
||||
}}
|
||||
/>
|
||||
<StyledInput
|
||||
autoComplete="off"
|
||||
onFocus={() => setFocusPosition('right')}
|
||||
ref={secondValueInputRef}
|
||||
placeholder={secondValuePlaceholder}
|
||||
value={secondInternalValue}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
handleChange(firstInternalValue, event.target.value);
|
||||
}}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,15 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { overlayBackground } from '@/ui/theme/constants/effects';
|
||||
|
||||
const StyledFieldInputOverlay = styled.div`
|
||||
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
${overlayBackground}
|
||||
display: flex;
|
||||
height: 32px;
|
||||
margin: -1px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const FieldInputOverlay = StyledFieldInputOverlay;
|
||||
@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import ReactPhoneNumberInput from 'react-phone-number-input';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { CountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/CountryPickerDropdownButton';
|
||||
|
||||
import { useRegisterInputEvents } from '../../../../object-record/field/meta-types/input/hooks/useRegisterInputEvents';
|
||||
|
||||
import 'react-phone-number-input/style.css';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
|
||||
border: none;
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)`
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
height: 32px;
|
||||
|
||||
.PhoneInputInput {
|
||||
background: ${({ theme }) => theme.background.transparent.secondary};
|
||||
border: none;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
|
||||
&::placeholder,
|
||||
&::-webkit-input-placeholder {
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
}
|
||||
|
||||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
& svg {
|
||||
border-radius: ${({ theme }) => theme.border.radius.xs};
|
||||
height: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
export type PhoneInputProps = {
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
value: string;
|
||||
onEnter: (newText: string) => void;
|
||||
onEscape: (newText: string) => void;
|
||||
onTab?: (newText: string) => void;
|
||||
onShiftTab?: (newText: string) => void;
|
||||
onClickOutside: (event: MouseEvent | TouchEvent, inputValue: string) => void;
|
||||
hotkeyScope: string;
|
||||
};
|
||||
|
||||
export const PhoneInput = ({
|
||||
autoFocus,
|
||||
value,
|
||||
onEnter,
|
||||
onEscape,
|
||||
onTab,
|
||||
onShiftTab,
|
||||
onClickOutside,
|
||||
hotkeyScope,
|
||||
}: PhoneInputProps) => {
|
||||
const [internalValue, setInternalValue] = useState<string | undefined>(value);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value);
|
||||
}, [value]);
|
||||
|
||||
useRegisterInputEvents({
|
||||
inputRef: wrapperRef,
|
||||
inputValue: internalValue ?? '',
|
||||
onEnter,
|
||||
onEscape,
|
||||
onClickOutside,
|
||||
onTab,
|
||||
onShiftTab,
|
||||
hotkeyScope,
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledContainer ref={wrapperRef}>
|
||||
<StyledCustomPhoneInput
|
||||
autoFocus={autoFocus}
|
||||
placeholder="Phone number"
|
||||
value={value}
|
||||
onChange={setInternalValue}
|
||||
international={true}
|
||||
withCountryCallingCode={true}
|
||||
countrySelectComponent={CountryPickerDropdownButton}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconTwentyStarFilled } from '@/ui/display/icon/components/IconTwentyStarFilled';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledRatingIconContainer = styled.div<{ isActive?: boolean }>`
|
||||
color: ${({ isActive, theme }) =>
|
||||
isActive ? theme.font.color.secondary : theme.background.quaternary};
|
||||
display: inline-flex;
|
||||
`;
|
||||
|
||||
type RatingInputProps = {
|
||||
onChange: (newValue: number) => void;
|
||||
value: number;
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
const RATING_LEVELS_NB = 5;
|
||||
|
||||
export const RatingInput = ({
|
||||
onChange,
|
||||
value,
|
||||
readonly,
|
||||
}: RatingInputProps) => {
|
||||
const theme = useTheme();
|
||||
const [hoveredValue, setHoveredValue] = useState<number | null>(null);
|
||||
const currentValue = hoveredValue ?? value;
|
||||
|
||||
return (
|
||||
<StyledContainer
|
||||
role="slider"
|
||||
aria-label="Rating"
|
||||
aria-valuemax={RATING_LEVELS_NB}
|
||||
aria-valuemin={1}
|
||||
aria-valuenow={value}
|
||||
tabIndex={0}
|
||||
>
|
||||
{Array.from({ length: RATING_LEVELS_NB }, (_, index) => {
|
||||
const rating = index + 1;
|
||||
|
||||
return (
|
||||
<StyledRatingIconContainer
|
||||
key={index}
|
||||
isActive={rating <= currentValue}
|
||||
onClick={readonly ? undefined : () => onChange(rating)}
|
||||
onMouseEnter={readonly ? undefined : () => setHoveredValue(rating)}
|
||||
onMouseLeave={readonly ? undefined : () => setHoveredValue(null)}
|
||||
>
|
||||
<IconTwentyStarFilled size={theme.icon.size.md} />
|
||||
</StyledRatingIconContainer>
|
||||
);
|
||||
})}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,70 @@
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { textInputStyle } from '@/ui/theme/constants/effects';
|
||||
|
||||
import { useRegisterInputEvents } from '../../../../object-record/field/meta-types/input/hooks/useRegisterInputEvents';
|
||||
|
||||
export const StyledInput = styled.input`
|
||||
margin: 0;
|
||||
${textInputStyle}
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type TextInputProps = {
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
value: string;
|
||||
onEnter: (newText: string) => void;
|
||||
onEscape: (newText: string) => void;
|
||||
onTab?: (newText: string) => void;
|
||||
onShiftTab?: (newText: string) => void;
|
||||
onClickOutside: (event: MouseEvent | TouchEvent, inputValue: string) => void;
|
||||
hotkeyScope: string;
|
||||
};
|
||||
|
||||
export const TextInput = ({
|
||||
placeholder,
|
||||
autoFocus,
|
||||
value,
|
||||
hotkeyScope,
|
||||
onEnter,
|
||||
onEscape,
|
||||
onTab,
|
||||
onShiftTab,
|
||||
onClickOutside,
|
||||
}: TextInputProps) => {
|
||||
const [internalText, setInternalText] = useState(value);
|
||||
|
||||
const wrapperRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setInternalText(event.target.value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setInternalText(value);
|
||||
}, [value]);
|
||||
|
||||
useRegisterInputEvents({
|
||||
inputRef: wrapperRef,
|
||||
inputValue: internalText,
|
||||
onEnter,
|
||||
onEscape,
|
||||
onClickOutside,
|
||||
onTab,
|
||||
onShiftTab,
|
||||
hotkeyScope,
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledInput
|
||||
autoComplete="off"
|
||||
ref={wrapperRef}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
autoFocus={autoFocus}
|
||||
value={internalText}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,317 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
|
||||
import { SoonPill } from '@/ui/display/pill/components/SoonPill';
|
||||
|
||||
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?: IconComponent;
|
||||
title?: string;
|
||||
fullWidth?: boolean;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
position?: ButtonPosition;
|
||||
accent?: ButtonAccent;
|
||||
soon?: boolean;
|
||||
disabled?: boolean;
|
||||
focus?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
};
|
||||
|
||||
const StyledButton = styled.button<
|
||||
Pick<
|
||||
ButtonProps,
|
||||
'fullWidth' | 'variant' | 'size' | 'position' | 'accent' | 'focus'
|
||||
>
|
||||
>`
|
||||
align-items: center;
|
||||
${({ theme, variant, accent, disabled, focus }) => {
|
||||
switch (variant) {
|
||||
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'
|
||||
? 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.border.color.danger
|
||||
: 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, 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-style: solid;
|
||||
border-width: ${({ variant, position }) => {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
case 'secondary':
|
||||
return position === 'middle' ? '1px 0px' : '1px';
|
||||
case 'tertiary':
|
||||
return '0';
|
||||
}
|
||||
}};
|
||||
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
font-weight: 500;
|
||||
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;
|
||||
|
||||
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSoonPill = styled(SoonPill)`
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
export const Button = ({
|
||||
className,
|
||||
Icon,
|
||||
title,
|
||||
fullWidth = false,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
accent = 'default',
|
||||
position = 'standalone',
|
||||
soon = false,
|
||||
disabled = false,
|
||||
focus = false,
|
||||
onClick,
|
||||
}: ButtonProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledButton
|
||||
fullWidth={fullWidth}
|
||||
variant={variant}
|
||||
size={size}
|
||||
position={position}
|
||||
disabled={soon || disabled}
|
||||
focus={focus}
|
||||
accent={accent}
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
>
|
||||
{Icon && <Icon size={theme.icon.size.sm} />}
|
||||
{title}
|
||||
{soon && <StyledSoonPill />}
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,57 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { ButtonPosition, ButtonProps } from './Button';
|
||||
|
||||
const StyledButtonGroupContainer = styled.div`
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export type ButtonGroupProps = Pick<
|
||||
ButtonProps,
|
||||
'variant' | 'size' | 'accent'
|
||||
> & {
|
||||
className?: string;
|
||||
children: ReactNode[];
|
||||
};
|
||||
|
||||
export const ButtonGroup = ({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
accent,
|
||||
}: ButtonGroupProps) => (
|
||||
<StyledButtonGroupContainer className={className}>
|
||||
{React.Children.map(children, (child, index) => {
|
||||
if (!React.isValidElement(child)) return null;
|
||||
|
||||
let position: ButtonPosition;
|
||||
|
||||
if (index === 0) {
|
||||
position = 'left';
|
||||
} else if (index === children.length - 1) {
|
||||
position = 'right';
|
||||
} else {
|
||||
position = 'middle';
|
||||
}
|
||||
|
||||
const additionalProps: any = { position, variant, accent, size };
|
||||
|
||||
if (variant) {
|
||||
additionalProps.variant = variant;
|
||||
}
|
||||
|
||||
if (accent) {
|
||||
additionalProps.variant = variant;
|
||||
}
|
||||
|
||||
if (size) {
|
||||
additionalProps.size = size;
|
||||
}
|
||||
|
||||
return React.cloneElement(child, additionalProps);
|
||||
})}
|
||||
</StyledButtonGroupContainer>
|
||||
);
|
||||
@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
|
||||
|
||||
export type FloatingButtonSize = 'small' | 'medium';
|
||||
export type FloatingButtonPosition = 'standalone' | 'left' | 'middle' | 'right';
|
||||
|
||||
export type FloatingButtonProps = {
|
||||
className?: string;
|
||||
Icon?: IconComponent;
|
||||
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 const FloatingButton = ({
|
||||
className,
|
||||
Icon,
|
||||
title,
|
||||
size = 'small',
|
||||
applyBlur = true,
|
||||
applyShadow = true,
|
||||
disabled = false,
|
||||
focus = false,
|
||||
}: FloatingButtonProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<StyledButton
|
||||
disabled={disabled}
|
||||
focus={focus && !disabled}
|
||||
size={size}
|
||||
applyBlur={applyBlur}
|
||||
applyShadow={applyShadow}
|
||||
className={className}
|
||||
>
|
||||
{Icon && <Icon size={theme.icon.size.sm} />}
|
||||
{title}
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
||||
@ -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: inline-flex;
|
||||
`;
|
||||
|
||||
export type FloatingButtonGroupProps = Pick<FloatingButtonProps, 'size'> & {
|
||||
children: React.ReactElement[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const FloatingButtonGroup = ({
|
||||
children,
|
||||
size,
|
||||
className,
|
||||
}: FloatingButtonGroupProps) => (
|
||||
<StyledFloatingButtonGroupContainer className={className}>
|
||||
{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>
|
||||
);
|
||||
@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
|
||||
|
||||
export type FloatingIconButtonSize = 'small' | 'medium';
|
||||
export type FloatingIconButtonPosition =
|
||||
| 'standalone'
|
||||
| 'left'
|
||||
| 'middle'
|
||||
| 'right';
|
||||
|
||||
export type FloatingIconButtonProps = {
|
||||
className?: string;
|
||||
Icon?: IconComponent;
|
||||
size?: FloatingIconButtonSize;
|
||||
position?: FloatingIconButtonPosition;
|
||||
applyShadow?: boolean;
|
||||
applyBlur?: boolean;
|
||||
disabled?: boolean;
|
||||
focus?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
const StyledButton = styled.button<
|
||||
Pick<
|
||||
FloatingIconButtonProps,
|
||||
'size' | 'position' | 'applyShadow' | 'applyBlur' | 'focus' | 'isActive'
|
||||
>
|
||||
>`
|
||||
align-items: center;
|
||||
backdrop-filter: ${({ applyBlur }) => (applyBlur ? 'blur(20px)' : 'none')};
|
||||
background: ${({ theme, isActive }) =>
|
||||
isActive ? theme.background.transparent.medium : 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'};
|
||||
box-sizing: border-box;
|
||||
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)};
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
transition: background ${({ theme }) => theme.animation.duration.instant}s
|
||||
ease;
|
||||
white-space: nowrap;
|
||||
|
||||
${({ position, size }) => {
|
||||
const sizeInPx =
|
||||
(size === 'small' ? 24 : 32) - (position === 'standalone' ? 0 : 4);
|
||||
|
||||
return `
|
||||
height: ${sizeInPx}px;
|
||||
width: ${sizeInPx}px;
|
||||
`;
|
||||
}}
|
||||
|
||||
&:hover {
|
||||
background: ${({ theme, isActive }) =>
|
||||
!!isActive ?? theme.background.transparent.lighter};
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: ${({ theme, disabled }) =>
|
||||
!disabled ? theme.background.transparent.medium : 'transparent'};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const FloatingIconButton = ({
|
||||
className,
|
||||
Icon,
|
||||
size = 'small',
|
||||
position = 'standalone',
|
||||
applyShadow = true,
|
||||
applyBlur = true,
|
||||
disabled = false,
|
||||
focus = false,
|
||||
onClick,
|
||||
isActive,
|
||||
}: FloatingIconButtonProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<StyledButton
|
||||
disabled={disabled}
|
||||
focus={focus && !disabled}
|
||||
size={size}
|
||||
applyShadow={applyShadow}
|
||||
applyBlur={applyBlur}
|
||||
className={className}
|
||||
position={position}
|
||||
onClick={onClick}
|
||||
isActive={isActive}
|
||||
>
|
||||
{Icon && <Icon size={theme.icon.size.md} />}
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,64 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
|
||||
|
||||
import {
|
||||
FloatingIconButton,
|
||||
FloatingIconButtonPosition,
|
||||
FloatingIconButtonProps,
|
||||
} from './FloatingIconButton';
|
||||
|
||||
const StyledFloatingIconButtonGroupContainer = styled.div`
|
||||
backdrop-filter: blur(20px);
|
||||
background-color: ${({ theme }) => theme.background.primary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
box-shadow: ${({ theme }) =>
|
||||
`0px 2px 4px 0px ${theme.background.transparent.light}, 0px 0px 4px 0px ${theme.background.transparent.medium}`};
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
`;
|
||||
|
||||
export type FloatingIconButtonGroupProps = Pick<
|
||||
FloatingIconButtonProps,
|
||||
'className' | 'size'
|
||||
> & {
|
||||
iconButtons: {
|
||||
Icon: IconComponent;
|
||||
onClick?: (event: MouseEvent<any>) => void;
|
||||
isActive?: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const FloatingIconButtonGroup = ({
|
||||
iconButtons,
|
||||
size,
|
||||
className,
|
||||
}: FloatingIconButtonGroupProps) => (
|
||||
<StyledFloatingIconButtonGroupContainer className={className}>
|
||||
{iconButtons.map(({ Icon, onClick, isActive }, index) => {
|
||||
const position: FloatingIconButtonPosition =
|
||||
iconButtons.length === 1
|
||||
? 'standalone'
|
||||
: index === 0
|
||||
? 'left'
|
||||
: index === iconButtons.length - 1
|
||||
? 'right'
|
||||
: 'middle';
|
||||
|
||||
return (
|
||||
<FloatingIconButton
|
||||
key={`floating-icon-button-${index}`}
|
||||
applyBlur={false}
|
||||
applyShadow={false}
|
||||
Icon={Icon}
|
||||
onClick={onClick}
|
||||
position={position}
|
||||
size={size}
|
||||
isActive={isActive}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</StyledFloatingIconButtonGroupContainer>
|
||||
);
|
||||
@ -0,0 +1,300 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
|
||||
|
||||
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 IconButtonProps = {
|
||||
className?: string;
|
||||
Icon?: IconComponent;
|
||||
variant?: IconButtonVariant;
|
||||
size?: IconButtonSize;
|
||||
position?: IconButtonPosition;
|
||||
accent?: IconButtonAccent;
|
||||
disabled?: boolean;
|
||||
focus?: boolean;
|
||||
dataTestId?: string;
|
||||
ariaLabel?: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
};
|
||||
|
||||
const StyledButton = styled.button<
|
||||
Pick<IconButtonProps, 'variant' | 'size' | 'position' | 'accent' | 'focus'>
|
||||
>`
|
||||
align-items: center;
|
||||
${({ theme, variant, accent, disabled, focus }) => {
|
||||
switch (variant) {
|
||||
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: transparent;
|
||||
border-color: ${
|
||||
variant === 'secondary'
|
||||
? theme.border.color.danger
|
||||
: 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, 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-style: solid;
|
||||
border-width: ${({ variant, position }) => {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
case 'secondary':
|
||||
return position === 'middle' ? '1px 0px' : '1px';
|
||||
case 'tertiary':
|
||||
return '0';
|
||||
}
|
||||
}};
|
||||
box-sizing: content-box;
|
||||
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
|
||||
display: flex;
|
||||
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;
|
||||
padding: 0;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
width: ${({ size }) => (size === 'small' ? '24px' : '32px')};
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const IconButton = ({
|
||||
className,
|
||||
Icon,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
accent = 'default',
|
||||
position = 'standalone',
|
||||
disabled = false,
|
||||
focus = false,
|
||||
dataTestId,
|
||||
ariaLabel,
|
||||
onClick,
|
||||
}: IconButtonProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<StyledButton
|
||||
data-testid={dataTestId}
|
||||
variant={variant}
|
||||
size={size}
|
||||
position={position}
|
||||
disabled={disabled}
|
||||
focus={focus}
|
||||
accent={accent}
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{Icon && <Icon size={theme.icon.size.md} />}
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,52 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
|
||||
|
||||
import { IconButton, IconButtonPosition, IconButtonProps } from './IconButton';
|
||||
|
||||
const StyledIconButtonGroupContainer = styled.div`
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export type IconButtonGroupProps = Pick<
|
||||
IconButtonProps,
|
||||
'accent' | 'size' | 'variant'
|
||||
> & {
|
||||
iconButtons: {
|
||||
Icon: IconComponent;
|
||||
onClick?: (event: MouseEvent<any>) => void;
|
||||
}[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const IconButtonGroup = ({
|
||||
accent,
|
||||
iconButtons,
|
||||
size,
|
||||
variant,
|
||||
className,
|
||||
}: IconButtonGroupProps) => (
|
||||
<StyledIconButtonGroupContainer className={className}>
|
||||
{iconButtons.map(({ Icon, onClick }, index) => {
|
||||
const position: IconButtonPosition =
|
||||
index === 0
|
||||
? 'left'
|
||||
: index === iconButtons.length - 1
|
||||
? 'right'
|
||||
: 'middle';
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
accent={accent}
|
||||
Icon={Icon}
|
||||
onClick={onClick}
|
||||
position={position}
|
||||
size={size}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</StyledIconButtonGroupContainer>
|
||||
);
|
||||
@ -0,0 +1,110 @@
|
||||
import React, { MouseEvent, useMemo } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { TablerIconsProps } from '@/ui/display/icon';
|
||||
|
||||
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 const 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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,112 @@
|
||||
import { ComponentProps, MouseEvent } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
|
||||
|
||||
export type LightIconButtonAccent = 'secondary' | 'tertiary';
|
||||
export type LightIconButtonSize = 'small' | 'medium';
|
||||
|
||||
export type LightIconButtonProps = {
|
||||
className?: string;
|
||||
testId?: string;
|
||||
Icon?: IconComponent;
|
||||
title?: string;
|
||||
size?: LightIconButtonSize;
|
||||
accent?: LightIconButtonAccent;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
focus?: boolean;
|
||||
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
} & Pick<ComponentProps<'button'>, 'aria-label' | 'title'>;
|
||||
|
||||
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 const LightIconButton = ({
|
||||
'aria-label': ariaLabel,
|
||||
className,
|
||||
testId,
|
||||
Icon,
|
||||
active = false,
|
||||
size = 'small',
|
||||
accent = 'secondary',
|
||||
disabled = false,
|
||||
focus = false,
|
||||
onClick,
|
||||
title,
|
||||
}: LightIconButtonProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<StyledButton
|
||||
data-testid={testId}
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
focus={focus && !disabled}
|
||||
accent={accent}
|
||||
className={className}
|
||||
size={size}
|
||||
active={active}
|
||||
title={title}
|
||||
>
|
||||
{Icon && <Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />}
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
|
||||
|
||||
type Variant = 'primary' | 'secondary';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
fullWidth?: boolean;
|
||||
variant?: Variant;
|
||||
soon?: boolean;
|
||||
} & React.ComponentProps<'button'>;
|
||||
|
||||
const StyledButton = styled.button<Pick<Props, 'fullWidth' | 'variant'>>`
|
||||
align-items: center;
|
||||
background: ${({ theme, variant, disabled }) => {
|
||||
if (disabled) {
|
||||
return theme.background.secondary;
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return theme.background.radialGradient;
|
||||
case 'secondary':
|
||||
return theme.background.primary;
|
||||
default:
|
||||
return theme.background.primary;
|
||||
}
|
||||
}};
|
||||
border: 1px solid;
|
||||
border-color: ${({ theme, disabled, variant }) => {
|
||||
if (disabled) {
|
||||
return theme.background.transparent.lighter;
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return theme.background.transparent.light;
|
||||
case 'secondary':
|
||||
return theme.border.color.medium;
|
||||
default:
|
||||
return theme.background.primary;
|
||||
}
|
||||
}};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
${({ theme, disabled }) => {
|
||||
if (disabled) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `box-shadow: ${theme.boxShadow.light};`;
|
||||
}}
|
||||
color: ${({ theme, variant, disabled }) => {
|
||||
if (disabled) {
|
||||
return theme.font.color.light;
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return theme.grayScale.gray0;
|
||||
case 'secondary':
|
||||
return theme.font.color.primary;
|
||||
default:
|
||||
return theme.font.color.primary;
|
||||
}
|
||||
}};
|
||||
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
|
||||
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
|
||||
${({ theme, variant }) => {
|
||||
switch (variant) {
|
||||
case 'secondary':
|
||||
return `
|
||||
&:hover {
|
||||
background: ${theme.background.tertiary};
|
||||
}
|
||||
`;
|
||||
default:
|
||||
return `
|
||||
&:hover {
|
||||
background: ${theme.background.radialGradientHover}};
|
||||
}
|
||||
`;
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
type MainButtonProps = Props & {
|
||||
Icon?: IconComponent;
|
||||
};
|
||||
|
||||
export const MainButton = ({
|
||||
Icon,
|
||||
title,
|
||||
fullWidth = false,
|
||||
variant = 'primary',
|
||||
type,
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
}: MainButtonProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<StyledButton
|
||||
className={className}
|
||||
{...{ disabled, fullWidth, onClick, type, variant }}
|
||||
>
|
||||
{Icon && <Icon size={theme.icon.size.sm} />}
|
||||
{title}
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,51 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
|
||||
|
||||
const StyledIconButton = styled.button`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.color.blue};
|
||||
border: none;
|
||||
|
||||
border-radius: 50%;
|
||||
color: ${({ theme }) => theme.font.color.inverted};
|
||||
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 20px;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
outline: none;
|
||||
padding: 0;
|
||||
transition:
|
||||
color 0.1s ease-in-out,
|
||||
background 0.1s ease-in-out;
|
||||
|
||||
&:disabled {
|
||||
background: ${({ theme }) => theme.background.quaternary};
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
cursor: default;
|
||||
}
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
type RoundedIconButtonProps = {
|
||||
Icon: IconComponent;
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
export const RoundedIconButton = ({
|
||||
Icon,
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
}: RoundedIconButtonProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledIconButton className={className} {...{ disabled, onClick }}>
|
||||
{<Icon size={theme.icon.size.md} />}
|
||||
</StyledIconButton>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
{/* Button.mdx */}
|
||||
|
||||
import { Meta, Controls, Story } from '@storybook/blocks';
|
||||
import * as ButtonStories from './Button.stories';
|
||||
|
||||
<Meta of={ButtonStories} />
|
||||
|
||||
Button is a clickable interactive element that triggers a response.
|
||||
|
||||
You can place text and icons inside of a button.
|
||||
|
||||
Buttons are often used for form submissions and to toggle elements into view.
|
||||
|
||||
<Story of={ButtonStories.Default} />
|
||||
<br />
|
||||
|
||||
## Properties
|
||||
|
||||
<Controls />
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
|
||||
import { Button } from '@/ui/button/components/Button';
|
||||
|
||||
<Button title='Click me' />
|
||||
```
|
||||
@ -0,0 +1,279 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { IconSearch } from '@/ui/display/icon';
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import {
|
||||
Button,
|
||||
ButtonAccent,
|
||||
ButtonPosition,
|
||||
ButtonSize,
|
||||
ButtonVariant,
|
||||
} from '../Button';
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'UI/Input/Button/Button',
|
||||
component: Button,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Default: Story = {
|
||||
argTypes: {
|
||||
Icon: { control: false },
|
||||
},
|
||||
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 Catalog: CatalogStory<Story, typeof Button> = {
|
||||
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: ['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 }),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [CatalogDecorator],
|
||||
};
|
||||
|
||||
export const SoonCatalog: CatalogStory<Story, typeof Button> = {
|
||||
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: '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 }),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [CatalogDecorator],
|
||||
};
|
||||
|
||||
export const PositionCatalog: CatalogStory<Story, typeof Button> = {
|
||||
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: '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],
|
||||
};
|
||||
@ -0,0 +1,77 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/display/icon';
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import { Button, ButtonAccent, ButtonSize, ButtonVariant } from '../Button';
|
||||
import { ButtonGroup } from '../ButtonGroup';
|
||||
|
||||
const meta: Meta<typeof ButtonGroup> = {
|
||||
title: 'UI/Input/Button/ButtonGroup',
|
||||
component: ButtonGroup,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ButtonGroup>;
|
||||
|
||||
export const Default: Story = {
|
||||
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: CatalogStory<Story, typeof ButtonGroup> = {
|
||||
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],
|
||||
};
|
||||
@ -0,0 +1,84 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { IconSearch } from '@/ui/display/icon';
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import { FloatingButton, FloatingButtonSize } from '../FloatingButton';
|
||||
|
||||
const meta: Meta<typeof FloatingButton> = {
|
||||
title: 'UI/Input/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: CatalogStory<Story, typeof FloatingButton> = {
|
||||
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],
|
||||
};
|
||||
@ -0,0 +1,59 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/display/icon';
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import { FloatingButton, FloatingButtonSize } from '../FloatingButton';
|
||||
import { FloatingButtonGroup } from '../FloatingButtonGroup';
|
||||
|
||||
const meta: Meta<typeof FloatingButtonGroup> = {
|
||||
title: 'UI/Input/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: CatalogStory<Story, typeof FloatingButtonGroup> = {
|
||||
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],
|
||||
};
|
||||
@ -0,0 +1,85 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { IconSearch } from '@/ui/display/icon';
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import {
|
||||
FloatingIconButton,
|
||||
FloatingIconButtonSize,
|
||||
} from '../FloatingIconButton';
|
||||
|
||||
const meta: Meta<typeof FloatingIconButton> = {
|
||||
title: 'UI/Input/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: CatalogStory<Story, typeof FloatingIconButton> = {
|
||||
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],
|
||||
};
|
||||
@ -0,0 +1,53 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/display/icon';
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import { FloatingIconButtonSize } from '../FloatingIconButton';
|
||||
import { FloatingIconButtonGroup } from '../FloatingIconButtonGroup';
|
||||
|
||||
const meta: Meta<typeof FloatingIconButtonGroup> = {
|
||||
title: 'UI/Input/Button/FloatingIconButtonGroup',
|
||||
component: FloatingIconButtonGroup,
|
||||
args: {
|
||||
iconButtons: [
|
||||
{ Icon: IconNotes },
|
||||
{ Icon: IconCheckbox },
|
||||
{ Icon: IconTimelineEvent },
|
||||
],
|
||||
},
|
||||
argTypes: {
|
||||
iconButtons: { control: false },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FloatingIconButtonGroup>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
size: 'small',
|
||||
},
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export const Catalog: CatalogStory<Story, typeof FloatingIconButtonGroup> = {
|
||||
argTypes: {
|
||||
size: { 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],
|
||||
};
|
||||
@ -0,0 +1,180 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { IconSearch } from '@/ui/display/icon';
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import {
|
||||
IconButton,
|
||||
IconButtonAccent,
|
||||
IconButtonPosition,
|
||||
IconButtonSize,
|
||||
IconButtonVariant,
|
||||
} from '../IconButton';
|
||||
|
||||
const meta: Meta<typeof IconButton> = {
|
||||
title: 'UI/Input/Button/IconButton',
|
||||
component: IconButton,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof IconButton>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
size: 'small',
|
||||
variant: 'primary',
|
||||
accent: 'danger',
|
||||
disabled: false,
|
||||
focus: false,
|
||||
position: 'standalone',
|
||||
Icon: IconSearch,
|
||||
},
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export const Catalog: CatalogStory<Story, typeof IconButton> = {
|
||||
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 PositionCatalog: CatalogStory<Story, typeof IconButton> = {
|
||||
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],
|
||||
};
|
||||
@ -0,0 +1,74 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/display/icon';
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import {
|
||||
IconButtonAccent,
|
||||
IconButtonSize,
|
||||
IconButtonVariant,
|
||||
} from '../IconButton';
|
||||
import { IconButtonGroup } from '../IconButtonGroup';
|
||||
|
||||
const meta: Meta<typeof IconButtonGroup> = {
|
||||
title: 'UI/Input/Button/IconButtonGroup',
|
||||
component: IconButtonGroup,
|
||||
args: {
|
||||
iconButtons: [
|
||||
{ Icon: IconNotes },
|
||||
{ Icon: IconCheckbox },
|
||||
{ Icon: IconTimelineEvent },
|
||||
],
|
||||
},
|
||||
argTypes: {
|
||||
iconButtons: { control: false },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof IconButtonGroup>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
size: 'small',
|
||||
variant: 'primary',
|
||||
accent: 'danger',
|
||||
},
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export const Catalog: CatalogStory<Story, typeof IconButtonGroup> = {
|
||||
argTypes: {
|
||||
size: { control: false },
|
||||
variant: { control: false },
|
||||
accent: { control: false },
|
||||
},
|
||||
parameters: {
|
||||
catalog: {
|
||||
dimensions: [
|
||||
{
|
||||
name: 'sizes',
|
||||
values: ['small', 'medium'] satisfies IconButtonSize[],
|
||||
props: (size: IconButtonSize) => ({ 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 }),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [CatalogDecorator],
|
||||
};
|
||||
@ -0,0 +1,88 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { IconSearch } from '@/ui/display/icon';
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import { LightButton, LightButtonAccent } from '../LightButton';
|
||||
|
||||
const meta: Meta<typeof LightButton> = {
|
||||
title: 'UI/Input/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: CatalogStory<Story, typeof LightButton> = {
|
||||
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],
|
||||
};
|
||||
@ -0,0 +1,97 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { IconSearch } from '@/ui/display/icon';
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import {
|
||||
LightIconButton,
|
||||
LightIconButtonAccent,
|
||||
LightIconButtonSize,
|
||||
} from '../LightIconButton';
|
||||
|
||||
const meta: Meta<typeof LightIconButton> = {
|
||||
title: 'UI/Input/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: CatalogStory<Story, typeof LightIconButton> = {
|
||||
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],
|
||||
};
|
||||
@ -0,0 +1,59 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { expect, fn, userEvent, within } from '@storybook/test';
|
||||
|
||||
import { IconBrandGoogle } from '@/ui/display/icon';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
|
||||
import { MainButton } from '../MainButton';
|
||||
|
||||
const clickJestFn = fn();
|
||||
|
||||
const meta: Meta<typeof MainButton> = {
|
||||
title: 'UI/Input/Button/MainButton',
|
||||
component: MainButton,
|
||||
decorators: [ComponentDecorator],
|
||||
args: { title: 'A primary Button', onClick: clickJestFn },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MainButton>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(clickJestFn).toHaveBeenCalledTimes(0);
|
||||
const button = canvas.getByRole('button');
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(clickJestFn).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: { Icon: IconBrandGoogle },
|
||||
};
|
||||
|
||||
export const DisabledWithIcon: Story = {
|
||||
args: { ...WithIcon.args, disabled: true },
|
||||
};
|
||||
|
||||
export const FullWidth: Story = {
|
||||
args: { fullWidth: true },
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: { title: 'A secondary Button', variant: 'secondary' },
|
||||
};
|
||||
|
||||
export const SecondaryWithIcon: Story = {
|
||||
args: { ...Secondary.args, ...WithIcon.args },
|
||||
};
|
||||
|
||||
export const SecondaryDisabledWithIcon: Story = {
|
||||
args: { ...SecondaryWithIcon.args, disabled: true },
|
||||
};
|
||||
|
||||
export const SecondaryFullWidth: Story = {
|
||||
args: { ...Secondary.args, ...FullWidth.args },
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { expect, fn, userEvent, within } from '@storybook/test';
|
||||
|
||||
import { IconArrowRight } from '@/ui/display/icon';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
|
||||
import { RoundedIconButton } from '../RoundedIconButton';
|
||||
|
||||
const clickJestFn = fn();
|
||||
|
||||
const meta: Meta<typeof RoundedIconButton> = {
|
||||
title: 'UI/Input/Button/RoundedIconButton',
|
||||
component: RoundedIconButton,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RoundedIconButton>;
|
||||
|
||||
export const Default: Story = {
|
||||
decorators: [ComponentDecorator],
|
||||
argTypes: { Icon: { control: false } },
|
||||
args: { onClick: clickJestFn, Icon: IconArrowRight },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(clickJestFn).toHaveBeenCalledTimes(0);
|
||||
const button = canvas.getByRole('button');
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(clickJestFn).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,239 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
AnimatePresence,
|
||||
AnimationControls,
|
||||
motion,
|
||||
useAnimation,
|
||||
} from 'framer-motion';
|
||||
|
||||
import { Checkmark } from '@/ui/display/checkmark/components/Checkmark';
|
||||
import { ColorScheme } from '@/workspace-member/types/WorkspaceMember';
|
||||
|
||||
const StyledColorSchemeBackground = styled.div<
|
||||
Pick<ColorSchemeCardProps, 'variant'>
|
||||
>`
|
||||
align-items: flex-end;
|
||||
background: ${({ variant, theme }) => {
|
||||
switch (variant) {
|
||||
case 'Dark':
|
||||
return theme.grayScale.gray75;
|
||||
case 'Light':
|
||||
default:
|
||||
return theme.grayScale.gray15;
|
||||
}
|
||||
}};
|
||||
border: ${({ variant, theme }) => {
|
||||
switch (variant) {
|
||||
case 'Dark':
|
||||
return `1px solid ${theme.grayScale.gray70};`;
|
||||
case 'Light':
|
||||
default:
|
||||
return `1px solid ${theme.grayScale.gray20};`;
|
||||
}
|
||||
}};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 80px;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
padding-left: ${({ theme }) => theme.spacing(6)};
|
||||
padding-top: ${({ theme }) => theme.spacing(6)};
|
||||
width: 120px;
|
||||
`;
|
||||
|
||||
const StyledColorSchemeContent = styled(motion.div)<
|
||||
Pick<ColorSchemeCardProps, 'variant'>
|
||||
>`
|
||||
background: ${({ theme, variant }) => {
|
||||
switch (variant) {
|
||||
case 'Dark':
|
||||
return theme.grayScale.gray75;
|
||||
case 'Light':
|
||||
return theme.grayScale.gray0;
|
||||
}
|
||||
}};
|
||||
|
||||
border-left: ${({ variant, theme }) => {
|
||||
switch (variant) {
|
||||
case 'Dark':
|
||||
return `1px solid ${theme.grayScale.gray60};`;
|
||||
case 'Light':
|
||||
default:
|
||||
return `1px solid ${theme.grayScale.gray20};`;
|
||||
}
|
||||
}};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md} 0px 0px 0px;
|
||||
border-top: ${({ variant, theme }) => {
|
||||
switch (variant) {
|
||||
case 'Dark':
|
||||
return `1px solid ${theme.grayScale.gray60};`;
|
||||
case 'Light':
|
||||
default:
|
||||
return `1px solid ${theme.grayScale.gray20};`;
|
||||
}
|
||||
}};
|
||||
box-sizing: border-box;
|
||||
color: ${({ variant, theme }) => {
|
||||
switch (variant) {
|
||||
case 'Dark':
|
||||
return theme.grayScale.gray30;
|
||||
case 'Light':
|
||||
default:
|
||||
return theme.grayScale.gray60;
|
||||
}
|
||||
}};
|
||||
display: flex;
|
||||
flex: 1;
|
||||
font-size: 20px;
|
||||
height: 56px;
|
||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||
padding-top: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
export type ColorSchemeSegmentProps = {
|
||||
variant: ColorScheme;
|
||||
controls: AnimationControls;
|
||||
className?: string;
|
||||
} & React.ComponentPropsWithoutRef<'div'>;
|
||||
|
||||
const ColorSchemeSegment = ({
|
||||
variant,
|
||||
controls,
|
||||
style,
|
||||
className,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: ColorSchemeSegmentProps) => (
|
||||
<StyledColorSchemeBackground
|
||||
className={className}
|
||||
{...{ variant, style, onClick, onMouseEnter, onMouseLeave }}
|
||||
>
|
||||
<StyledColorSchemeContent animate={controls} variant={variant}>
|
||||
Aa
|
||||
</StyledColorSchemeContent>
|
||||
</StyledColorSchemeBackground>
|
||||
);
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
position: relative;
|
||||
width: 120px;
|
||||
`;
|
||||
|
||||
const StyledMixedColorSchemeSegment = styled.div`
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 80px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 120px;
|
||||
`;
|
||||
|
||||
const StyledCheckmarkContainer = styled(motion.div)`
|
||||
bottom: 0px;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
`;
|
||||
|
||||
export type ColorSchemeCardProps = {
|
||||
variant: ColorScheme;
|
||||
selected?: boolean;
|
||||
} & React.ComponentPropsWithoutRef<'div'>;
|
||||
|
||||
const checkmarkAnimationVariants = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
};
|
||||
|
||||
export const ColorSchemeCard = ({
|
||||
variant,
|
||||
selected,
|
||||
onClick,
|
||||
}: ColorSchemeCardProps) => {
|
||||
const controls = useAnimation();
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
controls.start({
|
||||
height: 61,
|
||||
fontSize: '22px',
|
||||
transition: { duration: 0.1 },
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
controls.start({
|
||||
height: 56,
|
||||
fontSize: '20px',
|
||||
transition: { duration: 0.1 },
|
||||
});
|
||||
};
|
||||
|
||||
if (variant === 'System') {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledMixedColorSchemeSegment
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ColorSchemeSegment
|
||||
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
|
||||
controls={controls}
|
||||
variant="Light"
|
||||
/>
|
||||
<ColorSchemeSegment
|
||||
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}
|
||||
controls={controls}
|
||||
variant="Dark"
|
||||
/>
|
||||
</StyledMixedColorSchemeSegment>
|
||||
<AnimatePresence>
|
||||
{selected && (
|
||||
<StyledCheckmarkContainer
|
||||
key="system"
|
||||
variants={checkmarkAnimationVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Checkmark />
|
||||
</StyledCheckmarkContainer>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<ColorSchemeSegment
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
controls={controls}
|
||||
variant={variant}
|
||||
onClick={onClick}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{selected && (
|
||||
<StyledCheckmarkContainer
|
||||
key={variant}
|
||||
variants={checkmarkAnimationVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Checkmark />
|
||||
</StyledCheckmarkContainer>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user