Skip to content

Commit

Permalink
feat: add FormInput
Browse files Browse the repository at this point in the history
  • Loading branch information
aube-dev committed Sep 27, 2024
1 parent 02550f2 commit 8917f71
Show file tree
Hide file tree
Showing 12 changed files with 4,676 additions and 6,001 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
20 changes: 20 additions & 0 deletions app/components/common/FormInput/FormImageInput.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';

import FormInput from './FormInput';

const meta: Meta<typeof FormInput.Image> = {
component: FormInput.Image,
};

export default meta;

type Story = StoryObj<typeof FormInput.Image>;

export const Main: Story = {
args: {
isError: false,
multiple: false,
onImageChange: fn(),
},
};
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',
});
132 changes: 132 additions & 0 deletions app/components/common/FormInput/FormInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {
useRef,
useState,
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;
}

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>
);

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

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

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]);
setPreviewImageUrl(urls[0]);
}

e.currentTarget.value = '';
}}
/>
{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>
),
},
};
1 change: 1 addition & 0 deletions app/components/common/FormInput/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as FormInput } from './FormInput';
20 changes: 20 additions & 0 deletions app/root.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@ globalStyle('h1, h2, h3, h4, h5, h6, p', {
margin: 0,
});

globalStyle(
`input[type="text"],
input[type="password"],
input[type="submit"],
input[type="search"],
input[type="tel"],
input[type="email"],
html input[type="button"],
input[type="reset"]`,
{
appearance: 'none',
MozAppearance: 'none',
WebkitAppearance: 'none',
borderRadius: 0,
WebkitBorderRadius: 0,
MozBorderRadius: 0,
outline: 0,
},
);

export const loadingToast = style({
position: 'fixed',
bottom: '32px',
Expand Down
18 changes: 18 additions & 0 deletions app/utils/form.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const getDataURLFromFiles = async (...files: File[]) => {
const promises = files.map(
(file) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onloadend = () => {
if (reader.result && typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject();
}
};
}),
);
const urls = await Promise.all(promises);
return urls;
};
3 changes: 3 additions & 0 deletions app/utils/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,6 @@ export const getRGBFromHex = (hex: string) => {

export const rgba = (cssVar: string, alpha: number) =>
`rgba(${cssVar}, ${alpha})`;

export const className = (...classList: (string | undefined)[]) =>
classList.filter((item) => typeof item === 'string').join(' ');
Loading

0 comments on commit 8917f71

Please sign in to comment.