[김참솔] Sprint 10#134
Hidden character warning
Conversation
|
스프리트 미션 하시느라 수고 많으셨어요. |
| export default function TodoDetailTitle({ | ||
| name, | ||
| isCompleted, | ||
| onNameChange, | ||
| onCompletedChange, | ||
| }: { | ||
| name: string; | ||
| isCompleted: boolean; | ||
| onNameChange: (newName: string) => void; | ||
| onCompletedChange: (newCompleted: boolean) => void; | ||
| }) { | ||
| let className = styles.todoDetailTitle; | ||
| if (isCompleted) { | ||
| className += ` ${styles.checked}`; | ||
| } | ||
|
|
||
| const checkImage = isCompleted | ||
| ? "/images/checkbox-checked.svg" | ||
| : "/images/checkbox.svg"; | ||
|
|
||
| const handleClick = () => { | ||
| onCompletedChange(!isCompleted); | ||
| }; | ||
|
|
||
| const handleChange = (event: ChangeEvent<HTMLInputElement>) => { | ||
| const span = document.createElement("span"); | ||
| span.textContent = event.target.value; | ||
| onNameChange(event.target.value); | ||
| }; | ||
|
|
||
| return ( | ||
| <div className={className}> | ||
| <div className={styles.checkImage} onClick={handleClick}> | ||
| <img src={checkImage} alt="check" /> | ||
| </div> | ||
| <input className={styles.title} value={name} onChange={handleChange} /> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
<input/> 요소의 width를 입력된 text에 딱 맞게 조절하는 방법
-
할 일 제목을 즉시 수정하기 위해
<input/>tag를 사용할 때,<input/>요소의 최소 너비가 고정되어 있어서 text가 가운데 정렬되지 않는 문제가 있습니다. -
field-sizing이라는 CSS 속성을content로 설정하면 간단하게 해결되지만, 이 속성은 아직 공식 스펙이 아닌 것 같습니다. -
JavaScript를 사용하지 않고는 아직까지 이것을 구현하는 방법이 딱히 없는것인지 궁금합니다.
에 대한 답변드립니다 !
말씀주신대로 field-sizing은 다른 브라우저에서 제대로 지원이 안되는 것으로 보이네요.
파이어폭스를 켜서 실험해보니 동작되지 않는 이슈가 있었습니다.
처음에는 다음과 같은 해결 방안을 생각했습니다:
<div
className={styles.title}
value={name}
onChange={handleChange}
contenteditable="true"
role="textbox"
>{value}</div>근데 위와 같은 방법으로 하면 onChange 및 폼 제출에 대한 접근성도 떨어질 것으로 우려가 되는군요.
There was a problem hiding this comment.
숨겨진 요소로 width를 계산하면 어떨까?
css로만 문제를 해결하려 했으나 마땅히 해결할 수 있는 방안은 없을 것으로 판단되었습니다.
현재 구조가 어차피 키 입력 시 리렌더링이 되고 있고 리렌더링 될 때 width를 계산하여 가변적으로 조절해주면 기존과 큰 성능 이슈 없이 해결할 수 있을 것으로 판단하였습니다. 🤔
그런데, input은 기본적으로 width가 고정되어 있잖아요?
따라서 input의 width가 출력되었을 때를 예상하여 같은 스타일을 가진 숨겨진 미러가 있으면 어떨까요?(마치 이미지 업로드를 커스텀하는 것처럼요 !)
| }) { | ||
| let className = styles.todoDetailTitle; | ||
| if (isCompleted) { | ||
| className += ` ${styles.checked}`; | ||
| } |
There was a problem hiding this comment.
(이어서) 해당 코드에 inputRef와 spanRef 그리고 useEffect를 추가해줍니다.
| }) { | |
| let className = styles.todoDetailTitle; | |
| if (isCompleted) { | |
| className += ` ${styles.checked}`; | |
| } | |
| }) { | |
| const spanRef = useRef<HTMLSpanElement>(null); | |
| const inputRef = useRef<HTMLInputElement>(null); | |
| useEffect(() => { | |
| if (spanRef.current && inputRef.current) { | |
| const newWidth = spanRef.current.offsetWidth + 4; // 4는 여유 px | |
| inputRef.current.style.width = `${newWidth}px`; | |
| } | |
| }, [inputRef.current?.value, spanRef.current]); | |
| let className = styles.todoDetailTitle; | |
| if (isCompleted) { | |
| className += ` ${styles.checked}`; | |
| } |
| return ( | ||
| <div className={className}> | ||
| <div className={styles.checkImage} onClick={handleClick}> | ||
| <img src={checkImage} alt="check" /> | ||
| </div> | ||
| <input className={styles.title} value={name} onChange={handleChange} /> | ||
| </div> | ||
| ); |
There was a problem hiding this comment.
(이어서) 그리고 렌더링 코드에 미러를 추가합니다.
| return ( | |
| <div className={className}> | |
| <div className={styles.checkImage} onClick={handleClick}> | |
| <img src={checkImage} alt="check" /> | |
| </div> | |
| <input className={styles.title} value={name} onChange={handleChange} /> | |
| </div> | |
| ); | |
| return ( | |
| <div className={className}> | |
| <div className={styles.checkImage} onClick={handleClick}> | |
| <img src={checkImage} alt="check" /> | |
| </div> | |
| <div className={styles.title}> | |
| <input | |
| ref={inputRef} | |
| value={name} | |
| onChange={handleChange} | |
| /> | |
| <span ref={spanRef} className={styles.mirror}> | |
| {name} | |
| </span> | |
| </div> | |
| </div> | |
| ); |
⚠️ 미러 요소는input의 결과값 똑같은 스타일로 만들어줍니다 !
| .todoDetailTitle input { | ||
| field-sizing: content; | ||
| padding: 0; | ||
| background: none; | ||
| border: none; | ||
| outline: none; | ||
| font-size: 20px; | ||
| font-weight: 700; | ||
| line-height: 100%; | ||
| } |
There was a problem hiding this comment.
(이어서) 그리고 추가된 .mirror의 스타일을 똑같이 만들어줍니다
| .todoDetailTitle input { | |
| field-sizing: content; | |
| padding: 0; | |
| background: none; | |
| border: none; | |
| outline: none; | |
| font-size: 20px; | |
| font-weight: 700; | |
| line-height: 100%; | |
| } | |
| .todoDetailTitle input, | |
| .mirror { | |
| padding: 0; | |
| margin: 0; | |
| background: none; | |
| border: none; | |
| outline: none; | |
| font-family: inherit; | |
| font-size: 20px; | |
| font-weight: 700; | |
| line-height: 100%; | |
| text-decoration: inherit; | |
| } |
여기서 width에 영향을 받을만한 몇 가지 스타일을 추가했습니다. ✨
그리고 mirror는 유저에게 보이면 안되므로 다음 코드를 추가해줍니다:
.mirror {
position: absolute;
visibility: hidden;
white-space: pre;
pointer-events: none;
}There was a problem hiding this comment.
이제 input을 가변적인 크기로 스타일링 할 수 있게 됩니다 💪
2025-09-23.11.47.47.mov
혹시 모르니 수정된 코드 전문 첨부드릴게요 !:
diff --git a/my-app/components/todo/todo-detail-title.module.css b/my-app/components/todo/todo-detail-title.module.css
index f7d328d..688fb61 100644
--- a/my-app/components/todo/todo-detail-title.module.css
+++ b/my-app/components/todo/todo-detail-title.module.css
@@ -14,15 +14,24 @@
background-color: var(--color-violet-200);
}
-.todoDetailTitle input {
- field-sizing: content;
+.todoDetailTitle input,
+.mirror {
padding: 0;
+ margin: 0;
background: none;
border: none;
outline: none;
+ font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 100%;
+ text-decoration: inherit;
+}
+
+.title {
+ position: relative;
+ text-align: center;
+ min-width: 2ch;
}
.checkImage {
@@ -35,3 +44,10 @@
width: 100%;
height: 100%;
}
+
+.mirror {
+ position: absolute;
+ visibility: hidden;
+ white-space: pre;
+ pointer-events: none;
+}
diff --git a/my-app/components/todo/todo-detail-title.tsx b/my-app/components/todo/todo-detail-title.tsx
index 5bc34ed..3f870f8 100644
--- a/my-app/components/todo/todo-detail-title.tsx
+++ b/my-app/components/todo/todo-detail-title.tsx
@@ -1,4 +1,4 @@
-import { ChangeEvent } from "react";
+import { ChangeEvent, useEffect, useRef } from "react";
import styles from "./todo-detail-title.module.css";
export default function TodoDetailTitle({
@@ -12,6 +12,17 @@ export default function TodoDetailTitle({
onNameChange: (newName: string) => void;
onCompletedChange: (newCompleted: boolean) => void;
}) {
+ const spanRef = useRef<HTMLSpanElement>(null);
+ const inputRef = useRef<HTMLInputElement>(null);
+
+ useEffect(() => {
+ if (spanRef.current && inputRef.current) {
+ const newWidth = spanRef.current.offsetWidth + 4; // 4는 여유 px
+ inputRef.current.style.width = `${newWidth}px`;
+ }
+
+ }, [inputRef.current?.value, spanRef.current]);
+
let className = styles.todoDetailTitle;
if (isCompleted) {
className += ` ${styles.checked}`;
@@ -36,7 +47,16 @@ export default function TodoDetailTitle({
<div className={styles.checkImage} onClick={handleClick}>
<img src={checkImage} alt="check" />
</div>
- <input className={styles.title} value={name} onChange={handleChange} />
+ <div className={styles.title}>
+ <input
+ ref={inputRef}
+ value={name}
+ onChange={handleChange}
+ />
+ <span ref={spanRef} className={styles.mirror}>
+ {name}
+ </span>
+ </div>
</div>
);
}| const handleChange = (event: ChangeEvent<HTMLInputElement>) => { | ||
| const span = document.createElement("span"); | ||
| span.textContent = event.target.value; | ||
| onNameChange(event.target.value); | ||
| }; |
There was a problem hiding this comment.
span을 생성하기는 했는데 왜 생성했는지 잘 모르겠군요 !
| const handleChange = (event: ChangeEvent<HTMLInputElement>) => { | |
| const span = document.createElement("span"); | |
| span.textContent = event.target.value; | |
| onNameChange(event.target.value); | |
| }; | |
| const handleChange = (event: ChangeEvent<HTMLInputElement>) => { | |
| onNameChange(event.target.value); | |
| }; |
아마 저랑 비슷한 아이디어로 해결해보려 하셨던걸까요? 😉
There was a problem hiding this comment.
ㅎㅎ... 맞습니다. 삭제하는걸 까먹었네요
|
|
||
| export default function TodoDetailImagePreview({ | ||
| imageUrl, | ||
| onChange, | ||
| }: { | ||
| imageUrl?: string; | ||
| onChange: (file: File) => void; | ||
| }) { | ||
| const [previewUrl, setPreviewUrl] = useState<string | undefined | null>( | ||
| imageUrl | ||
| ); | ||
|
|
||
| const hasPreview = useMemo(() => { | ||
| return previewUrl !== "" && previewUrl != null; | ||
| }, [previewUrl]); | ||
|
|
||
| const handlePreviewChanged = (file: File | null, reset: () => void) => { | ||
| if (!file) return; | ||
|
|
||
| const maxSize = 5 * 1024 * 1024; |
There was a problem hiding this comment.
상수는 컴포넌트 바깥에 선언해볼 수 있습니다 😉
| export default function TodoDetailImagePreview({ | |
| imageUrl, | |
| onChange, | |
| }: { | |
| imageUrl?: string; | |
| onChange: (file: File) => void; | |
| }) { | |
| const [previewUrl, setPreviewUrl] = useState<string | undefined | null>( | |
| imageUrl | |
| ); | |
| const hasPreview = useMemo(() => { | |
| return previewUrl !== "" && previewUrl != null; | |
| }, [previewUrl]); | |
| const handlePreviewChanged = (file: File | null, reset: () => void) => { | |
| if (!file) return; | |
| const maxSize = 5 * 1024 * 1024; | |
| const MAX_FILE_SIZE = 5 * 1024 * 1024; | |
| export default function TodoDetailImagePreview({ | |
| imageUrl, | |
| onChange, | |
| }: { | |
| imageUrl?: string; | |
| onChange: (file: File) => void; | |
| }) { | |
| const [previewUrl, setPreviewUrl] = useState<string | undefined | null>( | |
| imageUrl | |
| ); | |
| const hasPreview = useMemo(() => { | |
| return previewUrl !== "" && previewUrl != null; | |
| }, [previewUrl]); | |
| const handlePreviewChanged = (file: File | null, reset: () => void) => { | |
| if (!file) return; |
핸들러가 실행될 때 마다 재선언이 될 것이며, 컴포넌트의 상태나 props를 참조하고 있지 않으므로(= 컴포넌트 내의 값을 참조하지 않으므로) 바깥에 선언해볼 수 있습니다 😊
| export async function getTodos(): Promise<Todo[]> { | ||
| try { | ||
| const client = new HttpClient(); | ||
| const items = await client.get("items"); | ||
| return items; | ||
| } catch (error) { | ||
| return []; | ||
| } | ||
| } | ||
|
|
||
| export async function getTodo(id: number): Promise<Todo | null> { | ||
| try { | ||
| const client = new HttpClient(); | ||
| const item = await client.get(`items/${id}`); | ||
| return item; | ||
| } catch (error) { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export async function addTodo(name: string): Promise<Todo | null> { | ||
| try { | ||
| const client = new HttpClient(); | ||
| const newItem = await client.post(`items`, { name }); | ||
| return newItem; | ||
| } catch (error) { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export async function toggleTodo(todo: Todo): Promise<Todo | null> { | ||
| try { | ||
| const client = new HttpClient(); | ||
| const updatedItem = await client.patch(`items/${todo.id}`, { | ||
| isCompleted: !todo.isCompleted, | ||
| }); | ||
| return updatedItem; | ||
| } catch (error) { | ||
| return null; | ||
| } | ||
| } |
There was a problem hiding this comment.
(제안) 굿굿. 여전히 깔끔한 API 함수군요. 다만, 예외 처리된 부분을 한 번 살펴볼까요?
현재 에러가 발생하여도 호출부(컴포넌트)에서는 "정상 동작 되는 것"으로 인식하게 될 것으로 보여요.
이렇게 되면 사용자에게 네트워킹 관련 에러나 서버로부터 받은 피드백 등을 출력해주기 어려울거예요.
따라서, catch에서 throw를 해보면 어떨까요?
API 함수는 API와 관련된 예외처리(로깅 등)을 하고 throw를 던져서 호출부에서 catch할 수 있도록 만들어볼 수 있을거예요.
만약 이렇게 한다면 프로덕트에서 사용하던 toast나 modal도 수월하게 사용해볼 수 있겠네요 😉
|
Page 전환 delay 문제
크으... 지금 타이밍에 정말 너무너무 좋은 고민이네요. App router의 경우 혹은 브라우저 단에 캐싱을 할 수 있는 React Query나 SWR을 활용하여 성능을 높일 수 있습니다. 노드 레벨(NextJs 서버)에서 캐싱을 하려면 Redis를 세팅해두는 방법
프리페치나 ISR을 적용하는게 가장 쉬워보이네요. |
|
TypeScript 사용 시 type 정의를 import할 때
아쉽게도.. 넵.. 제가 아는 선에서는 const text = "Hello";
type text = string;두 선언문이 식별되어야 할텐데, 아쉽게도 현재로서는 |
|
수고하셨습니다 참솔님 ! 이번 미션도 멋지게 해내셨네요 ! 😉 덕분에 좋은 꼼수(?) 하나 알아가네요 💪 이번 미션 수행하시느라 정말 수고 많으셨어요 참솔님 ~~! |




요구사항
할 일 수정
할 일 삭제
주요 변경사항
스크린샷
멘토에게
<input/>요소의 width를 입력된 text에 딱 맞게 조절하는 방법<input/>tag를 사용할 때,<input/>요소의 최소 너비가 고정되어 있어서 text가 가운데 정렬되지 않는 문제가 있습니다.field-sizing이라는 CSS 속성을content로 설정하면 간단하게 해결되지만, 이 속성은 아직 공식 스펙이 아닌 것 같습니다./)와 상세 페이지(/items/:id) 간에 page를 전환할 때 체감할 수 있을 정도의 delay가 발생합니다./) 접근 시 HTML을 로드하는 데에 약 0.9초 소요/items/:id) 접근 시 JS bundle을 로드하는 데에 약 0.8초 소요typekeyword 사용typekeyword를 사용하면 JavaScript 변환 시 type import 코드는 컴파일 과정에서 제외된다는 내용을 보면typekeyword를 항상 사용해야 할 것 같습니다. (관련 글)typekeyword는 자동완성이 되지 않아서 일일이 붙여주어야 해서 번거롭다는 느낌을 받았습니다.typekeyword를 꼭 붙여주어야 할까요?typekeyword도 자동완성 되도록 설정하는 방법이 있을까요?