Skip to content

Commit

Permalink
[ORT-13] feat: add FormInput, FormItem (#4)
Browse files Browse the repository at this point in the history
* feat: add `FormInput`

* feat: add `FormItem`
  • Loading branch information
aube-dev authored Sep 28, 2024
1 parent 02550f2 commit e99a56c
Show file tree
Hide file tree
Showing 19 changed files with 5,048 additions and 6,137 deletions.
1 change: 1 addition & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ module.exports = {
unnamedComponents: 'arrow-function',
},
],
'react/prop-types': 'off',
},
},

Expand Down
1 change: 1 addition & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const config: StorybookConfig = {
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-actions',
],
framework: {
name: '@storybook/react-vite',
Expand Down
37 changes: 37 additions & 0 deletions app/components/common/FormInput/FormImageInput.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { action } from '@storybook/addon-actions';
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';

import FormInput from './FormInput';

const meta: Meta<typeof FormInput.Image> = {
component: FormInput.Image,
decorators: [
function Decorator(Story, ctx) {
const [previewImageUrl, setPreviewImageUrl] = useState('');

return (
<Story
args={{
...ctx.args,
previewImageUrl,
onImageChange: (imageUrl) => {
action('onImageChange')(imageUrl);
setPreviewImageUrl(imageUrl);
},
}}
/>
);
},
],
};

export default meta;

type Story = StoryObj<typeof FormInput.Image>;

export const Main: Story = {
args: {
isError: false,
},
};
93 changes: 93 additions & 0 deletions app/components/common/FormInput/FormInput.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { style } from '@vanilla-extract/css';
import { Breakpoint } from '@/constants/style';
import { textStyle } from '@/styles/text.css';
import { themeVars } from '@/styles/theme.css';
import { getMediaQuery } from '@/utils/style';

export const textInputContainer = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
padding: '0 12px',
gap: '10px',
borderRadius: '8px',
backgroundColor: themeVars.color.background.article.hex,
borderColor: 'transparent',
borderStyle: 'solid',
borderWidth: '1px',
height: '33px',
'@media': {
[getMediaQuery([Breakpoint.MOBILE2, Breakpoint.MOBILE1])]: {
gap: '6px',
height: '31px',
},
},
});

export const blank = style({
color: themeVars.color.grayscale.gray5.hex,
});

export const textInputIcon = style({
width: '16px',
height: '16px',
'@media': {
[getMediaQuery([Breakpoint.MOBILE2, Breakpoint.MOBILE1])]: {
width: '12px',
height: '12px',
},
},
});

export const textInput = style([
textStyle.body1R,
{
flex: 1,
color: themeVars.color.grayscale.black.hex,
border: 0,
backgroundColor: 'transparent',
'::placeholder': {
color: themeVars.color.grayscale.gray5.hex,
},
},
]);

export const imageInputContainer = style({
width: '100px',
height: '100px',
borderRadius: '50px',
backgroundColor: themeVars.color.background.article.hex,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
appearance: 'none',
padding: 0,
border: 0,
cursor: 'pointer',
overflow: 'hidden',
'@media': {
[getMediaQuery([Breakpoint.MOBILE2, Breakpoint.MOBILE1])]: {
width: '80px',
height: '80px',
borderRadius: '40px',
},
},
});

export const imageInput = style({
display: 'none',
position: 'absolute',
});

export const imageInputPreview = style({
width: '100%',
height: '100%',
objectFit: 'cover',
});

export const error = style({
borderColor: themeVars.color.system.caution.hex,
borderStyle: 'solid',
borderWidth: '1px',
});
131 changes: 131 additions & 0 deletions app/components/common/FormInput/FormInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import {
useRef,
type DetailedHTMLProps,
type InputHTMLAttributes,
} from 'react';
import * as styles from './FormInput.css';
import { getDataURLFromFiles } from '@/utils/form';
import { className } from '@/utils/style';

interface FormInputPropsBase
extends DetailedHTMLProps<
InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
isError?: boolean;
}

export interface FormTextInputProps extends FormInputPropsBase {
Icon?: (props: { className?: string }) => JSX.Element;
onTextChange?: (newText: string) => void;
}

const FormTextInput = ({
Icon,
isError,
onTextChange,
...inputProps
}: FormTextInputProps) => (
<div
className={className(
styles.textInputContainer,
isError ? styles.error : undefined,
!inputProps.value ? styles.blank : undefined,
)}
>
{Icon && <Icon className={styles.textInputIcon} />}
<input
type="text"
{...inputProps}
className={className(styles.textInput, inputProps.className)}
onChange={(e) => {
inputProps.onChange?.(e);
onTextChange?.(e.currentTarget.value);
}}
/>
</div>
);

export interface FormImageInputProps extends FormInputPropsBase {
multiple?: false;
previewImageUrl?: string;
onImageChange?: (imageUrl: string) => void;
}

const FormImageInput = ({
isError,
multiple = false,
previewImageUrl,
onImageChange,
...inputProps
}: FormImageInputProps) => {
const inputElement = useRef<HTMLInputElement>(null);

return (
<button
className={className(
styles.imageInputContainer,
isError ? styles.error : undefined,
)}
onClick={() => {
inputElement.current?.click();
}}
>
{previewImageUrl?.length === 0 && (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.3829 9.12812C12.3829 7.75351 13.4973 6.63917 14.8719 6.63917C16.2465 6.63917 17.3608 7.75351 17.3608 9.12812C17.3608 10.5027 16.2465 11.6171 14.8719 11.6171C13.4973 11.6171 12.3829 10.5027 12.3829 9.12812ZM14.8719 7.78792C14.1317 7.78792 13.5317 8.38794 13.5317 9.12812C13.5317 9.86829 14.1317 10.4683 14.8719 10.4683C15.612 10.4683 16.2121 9.86829 16.2121 9.12812C16.2121 8.38794 15.612 7.78792 14.8719 7.78792ZM20.2595 16.1904C20.4241 15.7995 20.5574 15.3918 20.6563 14.9703C21.1146 13.0166 21.1146 10.9834 20.6563 9.02975C19.9945 6.20842 17.7916 4.00549 14.9703 3.3437C13.0166 2.88543 10.9834 2.88543 9.02975 3.3437C6.20842 4.00549 4.0055 6.20841 3.3437 9.02975C2.88543 10.9834 2.88543 13.0166 3.3437 14.9703C3.39229 15.1774 3.44919 15.3812 3.51403 15.5813L4.11782 14.9777C6.30157 12.7943 9.84209 12.7943 12.0258 14.9777L13.7165 16.668L14.1597 16.2249C15.8579 14.5271 18.5837 14.547 20.2595 16.1904ZM19.701 17.2801L19.6765 17.2506C18.4761 15.8104 16.2978 15.7116 14.9719 17.0373L14.5289 17.4802L16.9422 19.8931C18.0581 19.2711 18.9993 18.3786 19.6794 17.302C19.6868 17.2948 19.694 17.2875 19.701 17.2801ZM15.8264 20.4019L11.2136 15.79C9.47848 14.0552 6.66518 14.0552 4.93003 15.79L3.99195 16.7279C5.00173 18.6839 6.82731 20.1397 9.02975 20.6563C10.9834 21.1146 13.0166 21.1146 14.9703 20.6563C15.2626 20.5877 15.5484 20.5026 15.8264 20.4019Z"
fill="#9E9E9E"
/>
</svg>
)}
<input
ref={inputElement}
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif"
{...inputProps}
className={className(
styles.imageInput,

inputProps.className,
)}
multiple={multiple}
onChange={async (e) => {
inputProps.onChange?.(e);

e.preventDefault();
const files = e.target.files;

if (files && files[0]) {
const urls = await getDataURLFromFiles(files[0]);
onImageChange?.(urls[0]);
}

e.currentTarget.value = '';
}}
/>
{previewImageUrl && previewImageUrl.length > 0 && (
<img
src={previewImageUrl}
alt="preview before upload"
className={styles.imageInputPreview}
/>
)}
</button>
);
};

const FormInput = {
Text: FormTextInput,
Image: FormImageInput,
};

export default FormInput;
60 changes: 60 additions & 0 deletions app/components/common/FormInput/FormTextInput.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { action } from '@storybook/addon-actions';
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';

import FormInput from './FormInput';

const meta: Meta<typeof FormInput.Text> = {
component: FormInput.Text,
decorators: [
function Decorator(Story, ctx) {
const [value, setValue] = useState('');

return (
<Story
args={{
...ctx.args,
value,
onTextChange: (newText) => {
action('onTextChange')(newText);
setValue(newText);
},
}}
/>
);
},
],
};

export default meta;

type Story = StoryObj<typeof FormInput.Text>;

export const Main: Story = {
args: {
isError: false,
placeholder: '텍스트를 입력해주세요.',
},
};

export const WithIcon: Story = {
args: {
isError: false,
placeholder: '텍스트를 입력해주세요.',
Icon: ({ className }) => (
<svg
width="16"
height="17"
viewBox="0 0 16 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M2.21049 9.68737L2.94067 9.51609L2.21049 9.68737ZM2.21049 6.04933L2.94067 6.22061L2.21049 6.04933ZM12.5262 6.04934L13.2564 5.87806L12.5262 6.04934ZM12.5262 9.68737L13.2564 9.85864L12.5262 9.68737ZM9.18737 13.0262L9.01609 12.296L9.18737 13.0262ZM5.54934 13.0262L5.37806 13.7564L5.54934 13.0262ZM5.54933 2.71049L5.37806 1.9803L5.54933 2.71049ZM9.18737 2.71048L9.35864 1.9803L9.18737 2.71048ZM13.4697 15.0303C13.7626 15.3232 14.2374 15.3232 14.5303 15.0303C14.8232 14.7374 14.8232 14.2626 14.5303 13.9697L13.4697 15.0303ZM2.94067 9.51609C2.68644 8.43231 2.68644 7.3044 2.94067 6.22061L1.4803 5.87805C1.17323 7.18715 1.17323 8.54955 1.4803 9.85865L2.94067 9.51609ZM11.796 6.22061C12.0503 7.3044 12.0503 8.43231 11.796 9.51609L13.2564 9.85864C13.5635 8.54955 13.5635 7.18715 13.2564 5.87806L11.796 6.22061ZM9.01609 12.296C7.93231 12.5503 6.8044 12.5503 5.72061 12.296L5.37806 13.7564C6.68715 14.0635 8.04955 14.0635 9.35864 13.7564L9.01609 12.296ZM5.72061 3.44067C6.8044 3.18644 7.93231 3.18644 9.01609 3.44067L9.35864 1.9803C8.04955 1.67323 6.68715 1.67323 5.37806 1.9803L5.72061 3.44067ZM5.72061 12.296C4.34124 11.9725 3.26422 10.8955 2.94067 9.51609L1.4803 9.85865C1.93396 11.7927 3.44405 13.3027 5.37806 13.7564L5.72061 12.296ZM9.35864 13.7564C11.2927 13.3027 12.8027 11.7927 13.2564 9.85864L11.796 9.51609C11.4725 10.8955 10.3955 11.9725 9.01609 12.296L9.35864 13.7564ZM9.01609 3.44067C10.3955 3.76422 11.4725 4.84124 11.796 6.22061L13.2564 5.87806C12.8027 3.94405 11.2927 2.43396 9.35864 1.9803L9.01609 3.44067ZM5.37806 1.9803C3.44405 2.43396 1.93396 3.94405 1.4803 5.87805L2.94067 6.22061C3.26422 4.84124 4.34124 3.76422 5.72061 3.44067L5.37806 1.9803ZM11.0264 12.5871L13.4697 15.0303L14.5303 13.9697L12.0871 11.5264L11.0264 12.5871Z"
fill="currentColor"
/>
</svg>
),
},
};
3 changes: 3 additions & 0 deletions app/components/common/FormInput/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default } from './FormInput';

export type { FormTextInputProps, FormImageInputProps } from './FormInput';
40 changes: 40 additions & 0 deletions app/components/common/FormItem/FormImageItem.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { action } from '@storybook/addon-actions';
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';

import FormItem from './FormItem';

const meta: Meta<typeof FormItem.Image> = {
component: FormItem.Image,
decorators: [
function Decorator(Story, ctx) {
const [previewImageUrl, setPreviewImageUrl] = useState('');

return (
<Story
args={{
...ctx.args,
previewImageUrl,
onImageChange: (imageUrl) => {
action('onImageChange')(imageUrl);
setPreviewImageUrl(imageUrl);
},
}}
/>
);
},
],
};

export default meta;

type Story = StoryObj<typeof FormItem.Image>;

export const Main: Story = {
args: {
label: '이름',
optional: false,
errorMessage: '',
caption: '',
},
};
Loading

0 comments on commit e99a56c

Please sign in to comment.