Skip to content

Commit

Permalink
refact(TextArea): 기능 개선 (#157)
Browse files Browse the repository at this point in the history
* refact: textarea 기능 개선

* cs

---------

Co-authored-by: HyeongKyeom Kim <[email protected]>
Co-authored-by: Brokyeom <[email protected]>
  • Loading branch information
3 people authored Oct 10, 2024
1 parent 39d8ed5 commit cd5cd60
Show file tree
Hide file tree
Showing 4 changed files with 173 additions and 45 deletions.
5 changes: 5 additions & 0 deletions .changeset/polite-oranges-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sopt-makers/ui': minor
---

Textarea 구조 변경
6 changes: 3 additions & 3 deletions apps/docs/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,12 @@ function App() {
<TextArea
placeholder="Placeholder..."
required
labelText="Label"
descriptionText="description"
topAddon={{ labelText: "Label", descriptionText: "description" }}
rightAddon={{ onClick: () => handleTextareaSubmit() }}
validationFn={textareaValidation}
errorMessage="Error Message"
value={textarea}
onChange={handleTextareaChange}
onSubmit={handleTextareaSubmit}
maxLength={300}
/>
<SearchField
Expand Down
158 changes: 122 additions & 36 deletions packages/ui/Input/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,160 @@
import { useState, type ChangeEvent, type TextareaHTMLAttributes } from 'react';
import { useMemo, useRef, type ChangeEvent, type TextareaHTMLAttributes, isValidElement, useState } from 'react';
import * as S from './style.css';
import AlertCircleIcon from './icons/AlertCircleIcon';
import SendIcon from './icons/SendIcon';

interface TextAreaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value'> {
className?: string;
labelText?: string;
descriptionText?: string;
errorMessage?: string;
value: string;
maxLength: number;
// isError -> validationFn 순서로 적용
topAddon?: React.ReactNode | { labelText?: string; descriptionText?: string; };
rightAddon?: React.ReactNode | { buttonContent?: React.ReactNode; onClick: () => void; }; // ReactNode로 버튼을 전달하면 disabled 및 onKeyDown 직접처리 필요

isError?: boolean;
validationFn?: (input: string) => boolean;
onSubmit: () => void;
disableEnterSubmit?: boolean;
lineHeight?: number; // px
fixedHeight?: number; // px
validationFn?: (input: string) => boolean; // isError가 없을 때만 적용
errorMessage?: string; // isError 또는 validationFn 결과가 true일 때만 표시
value: string; // string 타입으로 한정

disableEnterSubmit?: boolean; // true일 경우, Enter 키는 줄바꿈으로 동작
maxLength?: number; // 없으면 무제한
fixedHeight?: number; // px -> 늘어나지 않도록 높이를 고정
maxHeight?: number; // px -> 늘어나면서 최대 높이를 제한
}

function TextArea(props: TextAreaProps) {
const { className, labelText, descriptionText, errorMessage, value, maxLength, isError, validationFn, onSubmit, disableEnterSubmit = false, lineHeight = 26, fixedHeight, ...inputProps } = props;
const {
className,
topAddon,
rightAddon,
isError,
validationFn,
errorMessage,
value,
disableEnterSubmit = false,
maxLength,
fixedHeight,
maxHeight = 130, // lineHeight가 26일 경우 5줄
...inputProps
} = props;
const { onChange, ...restInputProps } = inputProps;
const { disabled, readOnly, required } = restInputProps;

const isValid = validationFn ? validationFn(value) : true;
const isEmpty = value.length === 0;

const submitButtonRef = useRef<HTMLButtonElement | null>(null);

const [calcHeight, setCalcHeight] = useState(48);
const [isFocused, setIsFocused] = useState(false);

const hasError = () => {
if (inputProps.disabled || inputProps.readOnly) return false;
const hasError = useMemo(() => {
if (disabled || readOnly) return false;
if (isError !== undefined) return isError;
if (validationFn && !validationFn(value)) return true;
if (!isValid) return true;
return false;
}
}, [disabled, readOnly, isError, isValid]);

const disabled = inputProps.disabled || inputProps.readOnly || value.length === 0 || hasError();
const isSubmitDisabled = disabled || readOnly || isEmpty || hasError;

const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value;
const slicedText = text.slice(0, maxLength);
const slicedText = maxLength ? text.slice(0, maxLength) : text;
onChange && onChange({ ...e, target: { ...e.target, value: slicedText } });

// textarea rows
if (!fixedHeight) {
const lines = (slicedText.match(/\n/g) || []).length;
const height = 48 + lineHeight * (lines > 4 ? 4 : lines);
setCalcHeight(height);
e.target.style.height = '1px';
e.target.style.height = `${e.target.scrollHeight}px`;
}
}

const handleKeyPress = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!disableEnterSubmit && event.key === 'Enter' && !event.shiftKey) {
// Enter 키를 누르면 onClick 이벤트 발생
event.preventDefault();
!disabled && onSubmit();
!isSubmitDisabled && submitButtonRef.current?.click();
}
};

const buttonPosition = 48 + ((fixedHeight ?? calcHeight) - 48) / 2;
const labelText = useMemo(() => {
if (topAddon && typeof topAddon === 'object' && 'labelText' in topAddon) {
return topAddon.labelText;
}
}, [topAddon]);

const descriptionText = useMemo(() => {
if (topAddon && typeof topAddon === 'object' && 'descriptionText' in topAddon) {
return topAddon.descriptionText;
}
}, [topAddon]);

const submitButton = useMemo(() => {
if (rightAddon && typeof rightAddon === 'object' && 'onClick' in rightAddon) {
return (
<button className={S.textareaSubmitButton} disabled={isSubmitDisabled} onClick={rightAddon.onClick} ref={submitButtonRef} type="button">
{/* buttonContent 가 없을 경우 default로 SendIcon 표시 */}
{rightAddon.buttonContent ?? <SendIcon disabled={isSubmitDisabled} />}
</button>
);
}
}, [rightAddon, isSubmitDisabled]);

const handleFocus = () => {
setIsFocused(true);
}

const handleBlur = () => {
setIsFocused(false);
}

const requiredEl = required ? <span className={S.required}>*</span> : null;
const descriptionEl = descriptionText ? <p className={S.description}>{descriptionText}</p> : null;
const labelEl = labelText ? (
<label className={S.label} htmlFor={labelText}>
<span>{labelText}{requiredEl}</span>
{descriptionEl}
</label>
) : (
<div className={S.inputWrap}>{descriptionEl}</div>
);

const required = inputProps.required ? <span className={S.required}>*</span> : null;
const description = descriptionText ? <p className={S.description}>{descriptionText}</p> : null;
const input = <textarea {...restInputProps} className={`${S.input} ${S.textarea} ${hasError() ? S.inputError : ''}`} onChange={handleInputChange} onKeyDown={handleKeyPress} style={{ ...inputProps.style, height: `${fixedHeight ?? calcHeight}px` }} value={value} />;
return (
<div className={className}>
{isValidElement(topAddon) ? topAddon : labelEl}

return <div className={className} style={{ position: 'relative' }}>
{labelText ? <label className={S.label}><span>{labelText}{required}</span>{description}{input}</label> : <div className={S.inputWrap}>{description}{input}</div>}
<div className={`${S.textareaWrap} ${hasError ? S.inputError : ''} ${isFocused ? S.focus : ''}`}>
<textarea
{...restInputProps}
className={`${S.input} ${S.textarea}`}
id={labelText}
onBlur={handleBlur}
onChange={handleInputChange}
onFocus={handleFocus}
onKeyDown={inputProps.onKeyDown ?? handleKeyPress}
rows={1}
style={{ ...inputProps.style, height: fixedHeight ? `${fixedHeight}px` : 'auto', maxHeight: `${maxHeight}px` }}
value={value}
/>
{isValidElement(rightAddon) ? rightAddon : submitButton}
</div>

<button className={S.submitButton} disabled={disabled} onClick={onSubmit} style={{ transform: `translateY(-${buttonPosition}px)` }} type="submit"><SendIcon disabled={disabled} /></button>
{(hasError || maxLength) ? (
<div className={S.inputBottom}>
{hasError ? (
<div className={S.errorMessage}>
<AlertCircleIcon />
<p>{errorMessage ?? 'error'}</p>
</div>
) : (
<div> </div> // space-between 속성때문에 필요
)}

<div className={S.inputBottom}>
{hasError() ? <div className={S.errorMessage}><AlertCircleIcon /><p>{errorMessage ?? 'error'}</p></div> : <div> </div>}
<p className={`${S.count} ${value.length === maxLength ? S.maxCount : ''}`}>{value.length}/{maxLength}</p>
{maxLength ? (
<p className={`${S.count} ${value.length === maxLength ? S.maxCount : ''}`}>
{value.length}/{maxLength}
</p>
) : null}
</div>
) : null}
</div>
</div>
);
}

export default TextArea;
export default TextArea;
49 changes: 43 additions & 6 deletions packages/ui/Input/style.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,26 +40,45 @@ export const input = style({
},
});

export const textareaWrap = style({
height: 'fit-content',
display: 'flex',
alignItems: 'center',
border: '1px solid transparent',
borderRadius: '10px',
padding: '10px 0',
background: theme.colors.gray800,
});

export const textarea = style({
'resize': 'none',
'paddingRight': 0,
'display': 'block',
'padding': '0 8px 0 16px',
'marginRight': '8px',
'flex': 1,
'border': 0,
':focus': {
border: 0,
outline: 'none',
},

'::-webkit-scrollbar': {
width: '48px',
width: '4px',
},
'::-webkit-scrollbar-thumb': {
backgroundColor: theme.colors.gray500,
backgroundClip: 'padding-box',
border: '4px solid transparent',
boxShadow: `inset -36px 0 0 ${theme.colors.gray800}`,
borderRadius: '6px',
borderRadius: '4px',
},
'::-webkit-scrollbar-track': {
backgroundColor: 'transparent',
},
});

export const focus = style({
border: `1px solid ${theme.colors.gray200}`,
outline: 'none',
});

export const searchField = style({
paddingRight: '48px',
});
Expand Down Expand Up @@ -122,6 +141,20 @@ export const submitButton = style({
},
});

export const textareaSubmitButton = style({
'background': 'none',
'border': 'none',
'width': '34px',
'height': '100%',
'textAlign': 'left',
':hover': {
cursor: 'pointer',
},
':disabled': {
cursor: 'not-allowed',
},
});

export const selectWrap = style({
position: 'relative',
display: 'inline-block',
Expand Down Expand Up @@ -257,6 +290,10 @@ globalStyle(`${optionProfileEmpty} > svg`, {
height: '20px',
});

globalStyle(`${textareaWrap} > *`, {
flexShrink: 0,
});

export const buttonWithNoStyle = style({
background: 'none',
border: 'none',
Expand Down

0 comments on commit cd5cd60

Please sign in to comment.