Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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접근성개선`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/components/Overlays/ConfirmDialog/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>()
const titleId = useId()
const descriptionId = useId()

useScrollLock()

const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onCancel()
Expand Down
96 changes: 74 additions & 22 deletions src/components/Overlays/Modal/Modal.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,30 @@ const ModalPlayground = () => {
const { modalOpen } = useOverlay()

return (
<div className='w-[800px] h-[500px] flex items-center justify-center'>
<Button
size="lg"
variant="fill"
color="primary"
onClick={() =>
modalOpen({
config: { size: 'sm' },
content: onClose => (
<div className="flex flex-col gap-4 p-4 items-stretch justify-around">
<h2 className="text-yds-s1 text-white">모달 제목</h2>
<p className="text-yds-b2 text-gray-300">배경 클릭, ESC 키, 닫기 버튼으로 닫을 수 있습니다.</p>
<div className="flex justify-center">
<Button size="lg" variant="outlined" color="primary" onClick={onClose}>
<div className="w-[800px] h-[500px] flex items-center justify-center">
<Button
size="lg"
variant="fill"
color="primary"
onClick={() =>
modalOpen({
config: {
size: 'sm',
title: <span className="text-yds-s1 text-white">모달 제목</span>,
footer: onClose => (
<Button size="full" variant="outlined" color="primary" onClick={onClose}>
닫기
</Button>
</div>
</div>
),
})
}
>
모달 열기
</Button>
),
},
content: (
<p className="text-yds-b2 text-gray-300">배경 클릭, ESC 키, 닫기 버튼으로 닫을 수 있습니다.</p>
),
})
}
>
모달 열기
</Button>
</div>
)
}
Expand Down Expand Up @@ -123,12 +123,64 @@ export const Examples = {
</p>
<ModalFunctionDemo />
</div>

{/* 고정 헤더/푸터 */}
<div className="bg-background-secondary mb-8 rounded-lg p-8">
<h2 className="text-yds-s1 mb-4 text-white">고정 헤더 / 푸터 (Sticky Header & Footer)</h2>
<p className="text-yds-c1m mb-6 text-gray-300">
config에 title / footer를 넘기면 본문이 길어져도 제목은 상단, 버튼은 하단에 고정되고 가운데
본문만 스크롤됩니다.
</p>
<ModalStickyDemo />
</div>
</div>
</div>
)
},
}

/** 고정 헤더/푸터 데모 (긴 본문 스크롤) */
const ModalStickyDemo = () => {
const { modalOpen } = useOverlay()

return (
<Button
size="lg"
variant="fill"
color="primary"
onClick={() =>
modalOpen({
config: {
size: 'md',
title: <span className="text-yds-s1 text-white">고정 제목 영역</span>,
footer: onClose => (
<div className="flex gap-2">
<Button size="full" variant="outlined" color="primary" onClick={onClose}>
닫기
</Button>
<Button size="full" variant="fill" color="primary" onClick={onClose}>
확인
</Button>
</div>
),
},
content: (
<div className="flex flex-col gap-4">
{Array.from({ length: 30 }, (_, i) => (
<p key={i} className="text-yds-b2 text-gray-300">
{i + 1}. 본문이 길어져도 위 제목과 아래 버튼은 고정된 채 이 영역만 스크롤됩니다.
</p>
))}
</div>
),
})
}
>
긴 본문 모달 열기
</Button>
)
}

/** 사이즈별 모달 데모 */
const ModalSizesDemo = () => {
const { modalOpen } = useOverlay()
Expand Down
36 changes: 24 additions & 12 deletions src/components/Overlays/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>()
const titleId = useId()

useScrollLock()

//SECTION 메서드 영역
const handleCloseBubble = (e: React.MouseEvent<HTMLDivElement>) => {
Expand All @@ -25,16 +29,24 @@ export const Modal = ({ onClose, children, size }: IModal) => {
//!SECTION 메서드 영역

return (
<div
className="yds-modal-backdrop"
onClick={handleCloseBubble}
onKeyDown={handleKeyDown}
role="dialog"
aria-modal="true"
tabIndex={-1}
>
<div ref={focusTrapRef} className={modalSizeVariants({ size })} tabIndex={-1}>
{children}
<div className="yds-modal-backdrop" onClick={handleCloseBubble} onKeyDown={handleKeyDown} tabIndex={-1}>
<div
ref={focusTrapRef}
className={modalSizeVariants({ size })}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
tabIndex={-1}
>
{title && (
<header className="yds-modal-header">
<h2 id={titleId} className="yds-modal-title">
{title}
</h2>
</header>
)}
<div className="yds-modal-body">{children}</div>
{footer && <footer className="yds-modal-footer">{footer}</footer>}
</div>
</div>
)
Expand Down
11 changes: 11 additions & 0 deletions src/components/Overlays/Modal/ModalTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { ReactNode } from 'react'
export interface IModal extends VariantProps<typeof modalSizeVariants> {
onClose: () => void
children: React.ReactNode
/** 상단에 고정되는 제목 영역 (스크롤되지 않음) */
title?: React.ReactNode
/** 하단에 고정되는 버튼 영역 (스크롤되지 않음) */
footer?: React.ReactNode
}

export const modalSizeVariants = cva('yds-modal', {
Expand All @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions src/components/Overlays/hooks/useScrollLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useEffect } from 'react'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

⚡ SSR 대응 및 레이아웃 흔들림 방지를 위한 useLayoutEffect 도입

useEffect는 브라우저가 화면을 그린(paint) 후에 비동기적으로 실행되므로, 모달이 열릴 때 아주 잠깐 스크롤바가 보였다가 사라지면서 레이아웃이 미세하게 흔들리는 현상(Flicker)이 발생할 수 있습니다.

화면을 그리기 전에 동기적으로 스크롤을 잠그기 위해 useLayoutEffect를 사용하는 것이 좋습니다. 다만, SSR(Server-Side Rendering) 환경에서 useLayoutEffect를 그대로 사용하면 경고가 발생하므로, 환경에 따라 안전하게 대체되는 useSafeLayoutEffect 패턴을 제안합니다.

import { useEffect, useLayoutEffect } from 'react'

const useSafeLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect


/**
* 배경(body) 스크롤 잠금 훅.
*
* 모달/다이얼로그처럼 배경 조작을 막아야 하는 오버레이가 열려 있는 동안
* body 스크롤을 잠급니다. 여러 오버레이가 겹쳐 떠도 올바르게 동작하도록
* 모듈 단위 참조 카운팅을 사용합니다. (마지막 하나가 닫힐 때만 잠금 해제)
*
* 스크롤바가 사라지며 생기는 레이아웃 이동(콘텐츠 밀림)을 막기 위해
* 사라진 스크롤바 폭만큼 body에 padding-right를 보정합니다.
*/
let lockCount = 0
let originalOverflow = ''
let originalPaddingRight = ''

export function useScrollLock() {
useEffect(() => {
Comment on lines +17 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

정의한 useSafeLayoutEffect를 사용하여 브라우저가 화면을 그리기 전에 스크롤 잠금 및 패딩 보정이 동기적으로 적용되도록 합니다.

Suggested change
export function useScrollLock() {
useEffect(() => {
export function useScrollLock() {
useSafeLayoutEffect(() => {

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
}
}
}, [])
}
5 changes: 4 additions & 1 deletion src/components/Overlays/useOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
<Modal onClose={() => modalClose(id)} size={modalData.config.size}>
<Modal onClose={() => modalClose(id)} size={modalData.config.size} title={title} footer={resolvedFooter}>
{content}
</Modal>
),
Expand Down
26 changes: 24 additions & 2 deletions src/styles/token.components/token.components.modal.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment on lines +22 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

📐 모달 헤더/바디/푸터 여백 중첩 개선 (하위 호환성 유지)

현재 설정으로는 titlefooter가 모두 존재할 때, 각각 var(--yds-content-padding) 만큼의 패딩을 가지게 되어 영역 간의 간격이 너무 넓어지는 현상(더블 패딩)이 발생할 수 있습니다.

기존에 content만 단독으로 사용하던 경우의 하위 호환성(사방 패딩 유지)을 보장하면서, 헤더와 푸터가 추가되었을 때 영역 간의 간격을 자연스럽게 조절하기 위해 CSS 인접 형제 선택자(+)와 :not(:last-child) 가상 클래스를 활용하는 방식을 제안합니다.

  /* 상단 고정 영역 (스크롤되지 않음) */
  .yds-modal-header {
    flex-shrink: 0;
    padding: var(--yds-content-padding) var(--yds-content-padding) 0;
  }

  .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-header + .yds-modal-body {
    padding-top: calc(var(--yds-content-padding) / 2);
  }

  /* 푸터가 있을 경우 본문 하단 패딩 조절 (여백 중첩 방지) */
  .yds-modal-body:not(:last-child) {
    padding-bottom: calc(var(--yds-content-padding) / 2);
  }

  /* 하단 고정 영역 (스크롤되지 않음) */
  .yds-modal-footer {
    flex-shrink: 0;
    padding: 0 var(--yds-content-padding) var(--yds-content-padding);
  }



.yds-modal-size-sm {
Expand Down
Loading
Loading