-
Notifications
You must be signed in to change notification settings - Fork 26
[김참솔] Sprint 10 #134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[김참솔] Sprint 10 #134
The head ref may contain hidden characters: "Next-\uAE40\uCC38\uC194-sprint10"
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
<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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
숨겨진 요소로 width를 계산하면 어떨까?
css로만 문제를 해결하려 했으나 마땅히 해결할 수 있는 방안은 없을 것으로 판단되었습니다.
현재 구조가 어차피 키 입력 시 리렌더링이 되고 있고 리렌더링 될 때 width를 계산하여 가변적으로 조절해주면 기존과 큰 성능 이슈 없이 해결할 수 있을 것으로 판단하였습니다. 🤔
그런데, input은 기본적으로 width가 고정되어 있잖아요?
따라서 input의 width가 출력되었을 때를 예상하여 같은 스타일을 가진 숨겨진 미러가 있으면 어떨까요?(마치 이미지 업로드를 커스텀하는 것처럼요 !)
| }) { | ||
| let className = styles.todoDetailTitle; | ||
| if (isCompleted) { | ||
| className += ` ${styles.checked}`; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(이어서) 해당 코드에 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(이어서) 그리고 렌더링 코드에 미러를 추가합니다.
| 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(이어서) 그리고 추가된 .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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이제 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ㅎㅎ... 맞습니다. 삭제하는걸 까먹었네요
|
|
||
| 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
상수는 컴포넌트 바깥에 선언해볼 수 있습니다 😉
| 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(제안) 굿굿. 여전히 깔끔한 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도 자동완성 되도록 설정하는 방법이 있을까요?