From ba57dc3eec77f00877b7e14e1af2baa46a2f890c Mon Sep 17 00:00:00 2001 From: youngduck Date: Tue, 21 Jul 2026 12:07:12 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EB=AA=A8=EB=8B=AC=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=ED=97=A4=EB=8D=94=ED=91=B8=ED=84=B0=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EB=B0=8F=EB=B3=B8=EB=AC=B8=EC=8A=A4=ED=81=AC=EB=A1=A4=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config.title/footer로 상단·하단을 고정하고 가운데 본문만 스크롤되도록 Modal을 header/body/footer 3영역으로 재구성. body에 min-height:0+overflow-y 적용으로 스크롤 영역을 격리하고, role=dialog를 모달 박스로 이동하며 title에 aria-labelledby를 연결해 접근성을 보강. 기존 content-only 사용과 하위 호환 유지. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/Overlays/Modal/Modal.tsx | 33 ++++++++++++------- src/components/Overlays/Modal/ModalTypes.ts | 11 +++++++ src/components/Overlays/useOverlay.tsx | 5 ++- .../token.components.modal.css | 26 +++++++++++++-- 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/components/Overlays/Modal/Modal.tsx b/src/components/Overlays/Modal/Modal.tsx index 4115170..ed774cd 100644 --- a/src/components/Overlays/Modal/Modal.tsx +++ b/src/components/Overlays/Modal/Modal.tsx @@ -3,12 +3,13 @@ * 기능: * 프로세스 설명: 프로세스 복잡시 노션링크 첨부권장 */ -import React from 'react' +import React, { useId } from 'react' import { IModal, modalSizeVariants } from './ModalTypes' import { useFocusTrap } from '../hooks/useFocusTrap' -export const Modal = ({ onClose, children, size }: IModal) => { +export const Modal = ({ onClose, children, size, title, footer }: IModal) => { const focusTrapRef = useFocusTrap() + const titleId = useId() //SECTION 메서드 영역 const handleCloseBubble = (e: React.MouseEvent) => { @@ -25,16 +26,24 @@ export const Modal = ({ onClose, children, size }: IModal) => { //!SECTION 메서드 영역 return ( -
-
- {children} +
+
+ {title && ( +
+

+ {title} +

+
+ )} +
{children}
+ {footer &&
{footer}
}
) diff --git a/src/components/Overlays/Modal/ModalTypes.ts b/src/components/Overlays/Modal/ModalTypes.ts index 4dd5672..29a8dc6 100644 --- a/src/components/Overlays/Modal/ModalTypes.ts +++ b/src/components/Overlays/Modal/ModalTypes.ts @@ -4,6 +4,10 @@ import { ReactNode } from 'react' export interface IModal extends VariantProps { onClose: () => void children: React.ReactNode + /** 상단에 고정되는 제목 영역 (스크롤되지 않음) */ + title?: React.ReactNode + /** 하단에 고정되는 버튼 영역 (스크롤되지 않음) */ + footer?: React.ReactNode } export const modalSizeVariants = cva('yds-modal', { @@ -19,6 +23,13 @@ export const modalSizeVariants = cva('yds-modal', { export interface IModalConfig { size: 'sm' | 'md' | 'lg' | 'xl' + /** 상단에 고정되는 제목. 넘기면 스크롤과 무관하게 항상 보입니다. */ + title?: ReactNode + /** + * 하단에 고정되는 버튼 영역. 넘기면 스크롤과 무관하게 항상 보입니다. + * content와 동일하게 onClose 콜백을 받는 함수형도 지원합니다. + */ + footer?: ReactNode | ((onClose: () => void) => ReactNode) } export interface IModalOpenRequestData { diff --git a/src/components/Overlays/useOverlay.tsx b/src/components/Overlays/useOverlay.tsx index aaa8b76..11c7d31 100644 --- a/src/components/Overlays/useOverlay.tsx +++ b/src/components/Overlays/useOverlay.tsx @@ -23,10 +23,13 @@ export const useOverlay = () => { const content = typeof modalData.content === 'function' ? modalData.content(() => modalClose(id)) : modalData.content + const { title, footer } = modalData.config + const resolvedFooter = typeof footer === 'function' ? footer(() => modalClose(id)) : footer + mount({ id, component: ( - modalClose(id)} size={modalData.config.size}> + modalClose(id)} size={modalData.config.size} title={title} footer={resolvedFooter}> {content} ), diff --git a/src/styles/token.components/token.components.modal.css b/src/styles/token.components/token.components.modal.css index 03fc984..6a9439f 100644 --- a/src/styles/token.components/token.components.modal.css +++ b/src/styles/token.components/token.components.modal.css @@ -12,15 +12,37 @@ .yds-modal { background-color: var(--color-background-secondary); border-radius: var(--yds-border-radius); - padding: var(--yds-content-padding); display: flex; flex-direction: column; - overflow-y: auto; + /* 박스 전체 스크롤 제거 → 내부 body 영역만 스크롤 */ + overflow: hidden; z-index: var(--z-index-modal); } + /* 상단 고정 영역 (스크롤되지 않음) */ + .yds-modal-header { + flex-shrink: 0; + padding: var(--yds-content-padding); + } + .yds-modal-title { + margin: 0; + } + /* 스크롤되는 본문 영역 */ + .yds-modal-body { + flex: 1 1 auto; + /* flex 자식이 넘칠 때 스크롤되도록 하는 필수 조건 */ + min-height: 0; + overflow-y: auto; + padding: var(--yds-content-padding); + } + + /* 하단 고정 영역 (스크롤되지 않음) */ + .yds-modal-footer { + flex-shrink: 0; + padding: var(--yds-content-padding); + } .yds-modal-size-sm { From 47e1cc385f2ed1580f4351a0522ac8a5eab1885d Mon Sep 17 00:00:00 2001 From: youngduck Date: Tue, 21 Jul 2026 12:07:46 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=B0=B0=EA=B2=BD=EC=8A=A4=ED=81=AC=EB=A1=A4=EC=9E=A0?= =?UTF-8?q?=EA=B8=88=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모달·다이얼로그가 열려 있는 동안 body 스크롤을 잠그는 useScrollLock 훅 추가. 모듈단위 참조 카운팅으로 중첩 오버레이를 처리해 마지막 하나가 닫힐 때만 해제하고, 사라진 스크롤바 폭만큼 padding-right를 보정해 배경 밀림을 방지. Modal과 ConfirmDialog에 적용(Toast는 배경 조작 허용을 위해 제외). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Overlays/ConfirmDialog/ConfirmDialog.tsx | 3 ++ src/components/Overlays/Modal/Modal.tsx | 3 ++ .../Overlays/hooks/useScrollLock.ts | 44 +++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 src/components/Overlays/hooks/useScrollLock.ts diff --git a/src/components/Overlays/ConfirmDialog/ConfirmDialog.tsx b/src/components/Overlays/ConfirmDialog/ConfirmDialog.tsx index ff0c064..ef02b92 100644 --- a/src/components/Overlays/ConfirmDialog/ConfirmDialog.tsx +++ b/src/components/Overlays/ConfirmDialog/ConfirmDialog.tsx @@ -2,12 +2,15 @@ import React, { useId } from 'react' import { Button } from '../../Button/Button' import { IConfirmDialog } from './ConfirmDialogTypes' import { useFocusTrap } from '../hooks/useFocusTrap' +import { useScrollLock } from '../hooks/useScrollLock' export const ConfirmDialog = ({ title, description, confirmText, cancelText, onConfirm, onCancel }: IConfirmDialog) => { const focusTrapRef = useFocusTrap() const titleId = useId() const descriptionId = useId() + useScrollLock() + const handleBackdropClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onCancel() diff --git a/src/components/Overlays/Modal/Modal.tsx b/src/components/Overlays/Modal/Modal.tsx index ed774cd..766c8ae 100644 --- a/src/components/Overlays/Modal/Modal.tsx +++ b/src/components/Overlays/Modal/Modal.tsx @@ -6,11 +6,14 @@ import React, { useId } from 'react' import { IModal, modalSizeVariants } from './ModalTypes' import { useFocusTrap } from '../hooks/useFocusTrap' +import { useScrollLock } from '../hooks/useScrollLock' export const Modal = ({ onClose, children, size, title, footer }: IModal) => { const focusTrapRef = useFocusTrap() const titleId = useId() + useScrollLock() + //SECTION 메서드 영역 const handleCloseBubble = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { diff --git a/src/components/Overlays/hooks/useScrollLock.ts b/src/components/Overlays/hooks/useScrollLock.ts new file mode 100644 index 0000000..c03070e --- /dev/null +++ b/src/components/Overlays/hooks/useScrollLock.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react' + +/** + * 배경(body) 스크롤 잠금 훅. + * + * 모달/다이얼로그처럼 배경 조작을 막아야 하는 오버레이가 열려 있는 동안 + * body 스크롤을 잠급니다. 여러 오버레이가 겹쳐 떠도 올바르게 동작하도록 + * 모듈 단위 참조 카운팅을 사용합니다. (마지막 하나가 닫힐 때만 잠금 해제) + * + * 스크롤바가 사라지며 생기는 레이아웃 이동(콘텐츠 밀림)을 막기 위해 + * 사라진 스크롤바 폭만큼 body에 padding-right를 보정합니다. + */ +let lockCount = 0 +let originalOverflow = '' +let originalPaddingRight = '' + +export function useScrollLock() { + useEffect(() => { + const body = document.body + + if (lockCount === 0) { + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth + + originalOverflow = body.style.overflow + originalPaddingRight = body.style.paddingRight + + body.style.overflow = 'hidden' + if (scrollbarWidth > 0) { + const currentPaddingRight = parseInt(window.getComputedStyle(body).paddingRight, 10) || 0 + body.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px` + } + } + + lockCount += 1 + + return () => { + lockCount -= 1 + if (lockCount === 0) { + body.style.overflow = originalOverflow + body.style.paddingRight = originalPaddingRight + } + } + }, []) +} From e4e72e16f4a5ab55814499c13a5ee1c4baa2c3d6 Mon Sep 17 00:00:00 2001 From: youngduck Date: Tue, 21 Jul 2026 12:08:03 +0900 Subject: [PATCH 3/6] =?UTF-8?q?docs:=20=EB=AA=A8=EB=8B=AC=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=EA=B3=A0=EC=A0=95=ED=97=A4=EB=8D=94=ED=91=B8?= =?UTF-8?q?=ED=84=B0=EC=98=88=EC=A0=9C=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default 스토리의 제목·닫기 버튼을 title/footer 영역으로 이동해 정리하고, 긴 본문으로 고정 헤더/푸터 스크롤 동작을 보여주는 Sticky 예제를 추가. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Overlays/Modal/Modal.stories.tsx | 96 ++++++++++++++----- 1 file changed, 74 insertions(+), 22 deletions(-) diff --git a/src/components/Overlays/Modal/Modal.stories.tsx b/src/components/Overlays/Modal/Modal.stories.tsx index a0c8a02..4d3174d 100644 --- a/src/components/Overlays/Modal/Modal.stories.tsx +++ b/src/components/Overlays/Modal/Modal.stories.tsx @@ -17,30 +17,30 @@ const ModalPlayground = () => { const { modalOpen } = useOverlay() return ( -
- -
-
- ), - }) - } - > - 모달 열기 - + ), + }, + content: ( +

배경 클릭, ESC 키, 닫기 버튼으로 닫을 수 있습니다.

+ ), + }) + } + > + 모달 열기 +
) } @@ -123,12 +123,64 @@ export const Examples = {

+ + {/* 고정 헤더/푸터 */} +
+

고정 헤더 / 푸터 (Sticky Header & Footer)

+

+ config에 title / footer를 넘기면 본문이 길어져도 제목은 상단, 버튼은 하단에 고정되고 가운데 + 본문만 스크롤됩니다. +

+ +
) }, } +/** 고정 헤더/푸터 데모 (긴 본문 스크롤) */ +const ModalStickyDemo = () => { + const { modalOpen } = useOverlay() + + return ( + + + + ), + }, + content: ( +
+ {Array.from({ length: 30 }, (_, i) => ( +

+ {i + 1}. 본문이 길어져도 위 제목과 아래 버튼은 고정된 채 이 영역만 스크롤됩니다. +

+ ))} +
+ ), + }) + } + > + 긴 본문 모달 열기 + + ) +} + /** 사이즈별 모달 데모 */ const ModalSizesDemo = () => { const { modalOpen } = useOverlay() From 6e3a94f99e8768bf16fb0874186cf87d456ee3f2 Mon Sep 17 00:00:00 2001 From: youngduck Date: Tue, 21 Jul 2026 12:08:04 +0900 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20=EB=AA=A8=EB=8B=AC=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=EA=B3=A0=EC=A0=95=ED=97=A4=EB=8D=94=ED=91=B8=ED=84=B0?= =?UTF-8?q?=EB=B0=8F=EC=8A=A4=ED=81=AC=EB=A1=A4=EC=9E=A0=EA=B8=88=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs 사이트 modal.md에 title/footer API, 고정 헤더/푸터 사용법, 배경 스크롤 잠금, 접근성(aria-labelledby) 섹션과 API 표를 갱신. Co-Authored-By: Claude Opus 4.8 (1M context) --- ui-docs-site/components/modal.md | 43 ++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/ui-docs-site/components/modal.md b/ui-docs-site/components/modal.md index 0f443c3..ca349ae 100644 --- a/ui-docs-site/components/modal.md +++ b/ui-docs-site/components/modal.md @@ -48,6 +48,34 @@ modalOpen({ }) ``` +## 고정 헤더 / 푸터 (Sticky Header & Footer) + +`config.title`과 `config.footer`를 넘기면 본문이 길어져도 **제목은 상단, 버튼은 하단에 고정**되고 가운데 본문(`content`)만 스크롤됩니다. + +```tsx +modalOpen({ + config: { + size: 'md', + title: '고정 제목 영역', + footer: (onClose) => ( +
+ + +
+ ), + }, + content:
아무리 길어도 이 영역만 스크롤됩니다.
, +}) +``` + +- `title` / `footer`는 선택 옵션입니다. 넘기지 않으면 기존처럼 `content` 전체가 하나의 스크롤 영역이 됩니다. (하위 호환) +- `footer`는 `content`와 동일하게 `(onClose) => ReactNode` 함수형을 지원해 내부 버튼에서 닫기를 처리할 수 있습니다. +- `title`을 넘기면 `aria-labelledby`로 스크린리더에 제목이 연결됩니다. + ## Sizes `config.size`로 sm / md / lg / xl 사이즈를 선택할 수 있습니다. @@ -68,9 +96,18 @@ modalOpen({ config: { size: 'xl' }, content:
Extra Large
}) ## 접근성 -- `role="dialog"`, `aria-modal="true"` 적용 +- 모달 박스에 `role="dialog"`, `aria-modal="true"` 적용 +- `title`을 넘기면 `aria-labelledby`로 제목을 연결해 스크린리더가 모달 이름을 읽습니다 - 포커스 트랩으로 모달 외부 요소에 포커스 불가 +## 배경 스크롤 잠금 + +모달이 열려 있는 동안 배경(body) 스크롤이 잠깁니다. + +- 모달이 여러 개 겹쳐 떠도 참조 카운팅으로 관리되어, **마지막 모달이 닫힐 때만** 잠금이 해제됩니다 +- 스크롤바가 사라지며 생기는 배경 콘텐츠의 좌우 밀림(레이아웃 이동)을 자동 보정합니다 +- Toast는 배경 조작을 막지 않아야 하므로 스크롤 잠금 대상에서 제외됩니다 + ## 닫기 방식 - 배경(backdrop) 클릭 @@ -84,7 +121,9 @@ modalOpen({ config: { size: 'xl' }, content:
Extra Large
}) | 옵션 | 타입 | 기본값 | 설명 | |------|------|--------|------| | `config.size` | `'sm' \| 'md' \| 'lg' \| 'xl'` | - | 모달 크기 (필수) | -| `content` | `ReactNode \| ((onClose) => ReactNode)` | - | 모달 내용 (필수) | +| `config.title` | `ReactNode` | - | 상단 고정 제목 영역 (선택) | +| `config.footer` | `ReactNode \| ((onClose) => ReactNode)` | - | 하단 고정 버튼 영역 (선택) | +| `content` | `ReactNode \| ((onClose) => ReactNode)` | - | 스크롤되는 본문 (필수) | ### 사이즈별 크기 From ad1d323201341196ea56b19f22783c346e8e573d Mon Sep 17 00:00:00 2001 From: youngduck Date: Tue, 21 Jul 2026 12:08:34 +0900 Subject: [PATCH 5/6] =?UTF-8?q?chore:=20v0.20.1=EB=B2=84=EC=A0=84=EB=B2=94?= =?UTF-8?q?=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0706289..b2cbe9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@youngduck/yd-ui", - "version": "0.19.2", + "version": "0.20.1", "type": "module", "main": "./dist/index.cjs.js", "module": "./dist/index.esm.js", From 443dcea298213b881498249e7386ebb42ce706c0 Mon Sep 17 00:00:00 2001 From: youngduck Date: Tue, 21 Jul 2026 12:08:35 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20v0.20.1=20CHANGELOG=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2332a5..1e0a5e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [0.20.1] - 2026-07-21 + +**Branch**: `ui-v0.20.1-모달고정헤더푸터및배경스크롤잠금` +### 추가 +- feat: 모달고정헤더푸터추가및본문스크롤분리 +- feat: 오버레이배경스크롤잠금추가 + +### 변경 +- chore: v0.20.1버전범프 + +--- ## [0.19.2] - 2026-07-14 **Branch**: `ui-v0.19.2-Field접근성개선`