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접근성개선` 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", 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.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() diff --git a/src/components/Overlays/Modal/Modal.tsx b/src/components/Overlays/Modal/Modal.tsx index 4115170..766c8ae 100644 --- a/src/components/Overlays/Modal/Modal.tsx +++ b/src/components/Overlays/Modal/Modal.tsx @@ -3,12 +3,16 @@ * 기능: * 프로세스 설명: 프로세스 복잡시 노션링크 첨부권장 */ -import React from 'react' +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 }: IModal) => { +export const Modal = ({ onClose, children, size, title, footer }: IModal) => { const focusTrapRef = useFocusTrap() + const titleId = useId() + + useScrollLock() //SECTION 메서드 영역 const handleCloseBubble = (e: React.MouseEvent) => { @@ -25,16 +29,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/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 + } + } + }, []) +} 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 { 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)` | - | 스크롤되는 본문 (필수) | ### 사이즈별 크기