Skip to content

Commit

Permalink
NumberBox 구현했다
Browse files Browse the repository at this point in the history
  • Loading branch information
healtheloper committed Nov 30, 2023
1 parent 7e33e96 commit 58f38b0
Show file tree
Hide file tree
Showing 8 changed files with 364 additions and 1 deletion.
31 changes: 31 additions & 0 deletions packages/co-design-core/src/components/NumberBox/Count.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { RefObject, forwardRef, useImperativeHandle, useState } from 'react';
import { Typography, TypographyProps } from '../Typography';
import { useNumberBoxContext } from './NumberBoxContext';
import { useUpdateEffect } from '@co-design/hooks';

export interface CountRef {
count: number;
}

interface CountProps extends TypographyProps<'span'> {}

export const Count = forwardRef((props: CountProps, ref: RefObject<CountRef>) => {
const { isDecrease, currentCount, deletedCount } = useNumberBoxContext();
const [count, setCount] = useState(currentCount);

useImperativeHandle(ref, () => ({
count,
}));

useUpdateEffect(() => {
if (isDecrease && count >= deletedCount) {
setCount((prev) => prev - 1);
}
}, [currentCount]);

return (
<Typography variant="caption" {...props}>
{count}
</Typography>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { createStyles } from '@co-design/styles';

interface NumberBoxStyles {
checked?: boolean;
disabled?: boolean;
size?: 'small' | 'medium';
}

export default createStyles((theme, { checked, disabled, size }: NumberBoxStyles, getRef) => {
const checkmark = getRef('checkmark');
const wrapper = getRef('wrapper');

const positionSizes = {
small: theme.foundations.tokens.size.size_02,
medium: theme.foundations.tokens.size.size_04,
};

const iconSizes = {
small: theme.foundations.tokens.size.size_08,
medium: theme.foundations.tokens.size.size_09,
};

return {
wrapper: {
ref: wrapper,
position: 'relative',
cursor: 'pointer',
userSelect: 'none',
verticalAlign: 'middle',
zIndex: 0,
width: 'fit-content',
...(!disabled
? {
'&:hover': {
[`.${checkmark}`]: {
fill: checked ? theme.foundations.tokens.color.bg.bg_primary_hover : theme.foundations.tokens.color.bg.bg_base_light,
},
},
'&:focus': {
[`.${checkmark}`]: {
color: theme.foundations.tokens.color.border.border_primary,
},
},
}
: {}),
},
input: {
position: 'absolute',
width: '0px',
height: '0px',
opacity: 0,
},
childrenWrapper: {
position: 'relative',
width: 'fit-content',
},
checkWrapper: {
position: 'absolute',
top: theme.fn.size({ size, sizes: positionSizes }),
right: theme.fn.size({ size, sizes: positionSizes }),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
checkmark: {
ref: checkmark,
color: theme.foundations.tokens.color.border.border_strong,
fill: theme.foundations.tokens.color.bg.bg_surface_01,
width: theme.fn.size({ size, sizes: iconSizes }),
height: theme.fn.size({ size, sizes: iconSizes }),
},
checked: {
[`.${checkmark}`]: {
color: theme.foundations.tokens.color.icon.icon_inverse_white,
fill: theme.foundations.tokens.color.bg.bg_primary,
},
},
disabled: {
cursor: 'not-allowed',
[`.${checkmark}`]: {
// TODO: replace with color token
color: checked ? 'rgba(0,0,0,0)' : theme.foundations.tokens.color.border.border_disabled,
fill: theme.foundations.tokens.color.bg.bg_disabled,
},
},
number: {
position: 'absolute',
color: disabled ? theme.foundations.tokens.color.icon.icon_disabled : theme.foundations.tokens.color.text.text_light,
opacity: checked ? 1 : 0,
},
};
});
97 changes: 97 additions & 0 deletions packages/co-design-core/src/components/NumberBox/NumberBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import React, { useEffect, useRef, useState } from 'react';
import { ClassNames, CoComponentProps } from '@co-design/styles';
import { NumberBoxIcon } from './NumberBoxIcon';
import { View } from '../View';
import useStyles from './NumberBox.style';
import { Count, CountRef } from './Count';
import { useNumberBoxContext } from './NumberBoxContext';

export type NumberBoxStylesNames = ClassNames<typeof useStyles>;

export interface NumberBoxProps extends CoComponentProps<NumberBoxStylesNames>, Omit<React.ComponentPropsWithoutRef<'input'>, 'onClick' | 'size'> {
/** NumberBox 를 감싸는 요소를 클릭했을 때 발생할 이벤트를 지정합니다. */
onClick?: React.MouseEventHandler<HTMLLabelElement>;

size?: 'small' | 'medium';

/** NumberBox 를 감싸는 요소에 속성을 전달합니다. */
wrapperProps?: React.ComponentPropsWithoutRef<'label'> & { [key: string]: any };
}

export const NumberBox = ({
name,
value,
checked = false,
disabled = false,
onChange,
onClick,
size = 'small',
className = '',
style,
wrapperProps,
co,
overrideStyles,
children,
...props
}: NumberBoxProps) => {
const { actions } = useNumberBoxContext();
const countRef = useRef<CountRef>({ count: 0 });

const [check, setCheck] = useState(checked);
const { classes, cx } = useStyles(
{ checked: check, disabled, size },
{
overrideStyles,
name: 'NumberBox',
},
);

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const checked = event.target.checked;

if (checked) {
actions.increase();
} else {
actions.delete(countRef.current.count);
}

setCheck(checked);
onChange?.(event);
};

useEffect(() => {
setCheck(checked);
}, [checked]);

return (
<View
component="label"
onClick={onClick}
className={cx(classes.wrapper, className, {
[classes.checked]: check,
[classes.disabled]: disabled,
})}
co={co}
style={style}
{...wrapperProps}
>
<input
type="checkbox"
className={classes.input}
name={name}
checked={check}
disabled={disabled}
value={value}
onChange={handleChange}
{...props}
/>
<View className={classes.childrenWrapper}>
{children}
<View className={classes.checkWrapper}>
<NumberBoxIcon className={classes.checkmark} />
{check && <Count ref={countRef} className={classes.number} />}
</View>
</View>
</View>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createContext, ReactNode, useContext, useState } from 'react';

interface NumberBoxActions {
increase: () => void;
delete: (count: number) => void;
}

type INumberBoxContext = { isDecrease: boolean; currentCount: number; deletedCount: number; actions: NumberBoxActions };

const NumberBoxContext = createContext<INumberBoxContext>(undefined as unknown as INumberBoxContext);

export const useNumberBoxContext = () => {
const context = useContext(NumberBoxContext);
if (!context) {
throw new Error('useMe must be used within a NumberBoxProvider');
}
return context;
};

interface Props {
children: ReactNode;
}

export const NumberBoxProvider = ({ children }: Props) => {
const [currentCount, setCurrentCount] = useState(0);
const [deletedCount, setDeletedCount] = useState<number>();
const [isDecrease, setIsDecrease] = useState<boolean>(false);

const actions = {
increase: () => {
setCurrentCount((prev) => prev + 1);
setDeletedCount(undefined);
setIsDecrease(false);
},
delete: (count: number) => {
setCurrentCount((prev) => prev - 1);
setIsDecrease(true);
setDeletedCount(count);
},
};

return <NumberBoxContext.Provider value={{ isDecrease, currentCount, deletedCount, actions }}>{children}</NumberBoxContext.Provider>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { SVGProps } from 'react';

export const NumberBoxIcon = (props: SVGProps<SVGSVGElement>) => {
return (
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<rect x="1.6665" y="1" width="18" height="18" rx="9" fill="currentFill" stroke="currentColor" strokeWidth="2" />
</svg>
);
};
3 changes: 3 additions & 0 deletions packages/co-design-core/src/components/NumberBox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { NumberBox } from './NumberBox';

export type { NumberBoxProps } from './NumberBox';
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { NumberBox } from '../NumberBox';
import { NumberBoxProvider } from '../NumberBoxContext';
import { Stack } from '../../Stack';
import { Button } from '../../Button';

type Story = StoryObj<typeof NumberBox>;

export default {
title: '@co-design/core/NumberBox',
component: NumberBox,
argTypes: {
checked: {
control: { type: 'boolean' },
},
disabled: {
control: { type: 'boolean' },
},
size: {
options: ['small', 'medium'],
control: { type: 'inline-radio' },
},
},
} as Meta<typeof NumberBox>;

export const Default: Story = {
render: (props) => {
return (
<NumberBoxProvider>
<NumberBox {...props}>
<img src="https://via.placeholder.com/150" />
</NumberBox>
</NumberBoxProvider>
);
},
};

export const MultiSelect: Story = {
render: (props) => {
return (
<NumberBoxProvider>
<Stack>
<NumberBox {...props}>
<img src="https://via.placeholder.com/150" />
</NumberBox>
<NumberBox {...props}>
<img src="https://via.placeholder.com/150" />
</NumberBox>
<NumberBox {...props}>
<img src="https://via.placeholder.com/150" />
</NumberBox>
<NumberBox {...props}>
<img src="https://via.placeholder.com/150" />
</NumberBox>
<NumberBox {...props}>
<img src="https://via.placeholder.com/150" />
</NumberBox>
</Stack>
</NumberBoxProvider>
);
},
};

export const Disabled: Story = {
render: (props) => {
const [disabled, setDisabled] = useState(false);

return (
<NumberBoxProvider>
<Stack>
<Button
onClick={() => {
setDisabled((prev) => !prev);
}}
>
Toggle disabled
</Button>
<NumberBox disabled={disabled} {...props}>
<img src="https://via.placeholder.com/150" />
</NumberBox>
<NumberBox disabled={disabled} {...props}>
<img src="https://via.placeholder.com/150" />
</NumberBox>
</Stack>
</NumberBoxProvider>
);
},
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import React from 'react';
import Radio from '../Radio';
import { Meta, StoryObj } from '@storybook/react';

Expand Down

0 comments on commit 58f38b0

Please sign in to comment.