diff --git a/src/components/UpdateModal/UpdateModal.stories.tsx b/src/components/UpdateModal/UpdateModal.stories.tsx index 1581fed0..d1cf60e4 100644 --- a/src/components/UpdateModal/UpdateModal.stories.tsx +++ b/src/components/UpdateModal/UpdateModal.stories.tsx @@ -1,13 +1,174 @@ import { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, waitFor, within } from '@storybook/test'; +import { ComponentProps, useEffect, useMemo } from 'react'; +import { useArgs } from '@storybook/preview-api'; +import { createInstance } from 'i18next'; +import { I18nextProvider } from 'react-i18next'; +import { useModal } from '../../hooks/useModal'; import UpdateModal from './UpdateModal'; import PatchNoteImageKorean from '../../assets/patchNote/0003_ko.png'; import PatchNoteImageEnglish from '../../assets/patchNote/0003_en.png'; -import { PredefinedPatchNoteData } from '../../constants/patch_note'; +import { LATEST_PATCH_NOTE } from '../../constants/patch_note'; + +const STORY_I18N = createInstance(); +void STORY_I18N.init({ + lng: 'ko', + fallbackLng: 'ko', + resources: { + ko: { translation: {} }, + en: { + translation: { + '디베이트 타이머에 새로운 기능이 생겼어요!': + 'New features are now available in Debate Timer!', + '업데이트 이미지': 'Update image', + '일주일간 보지 않기': "Don't show again for a week", + 닫기: 'Close', + }, + }, + }, +}); + +interface ModalPreviewProps extends ComponentProps { + language: 'ko' | 'en'; +} + +function ModalPreview(props: ModalPreviewProps) { + const { language, ...modalProps } = props; + const storyI18n = useMemo( + () => STORY_I18N.cloneInstance({ lng: language }), + [language], + ); + const { openModal, closeModal, ModalWrapper } = useModal({ + isCloseButtonExist: false, + onClose: props.onClose, + }); + + useEffect(() => { + openModal(); + }, [openModal]); + + return ( + + + + + + ); +} const meta: Meta = { title: 'components/UpdateModal', component: UpdateModal, tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + language: 'ko', + docs: { story: { inline: false, iframeHeight: 900 } }, + }, + render: function Render(args, context) { + const [, updateArgs] = useArgs(); + const handleCheckedChange = (value: boolean) => { + updateArgs({ isChecked: value }); + args.onChecked(value); + }; + return ( + + ); + }, + play: async ({ canvasElement, step, args, parameters }) => { + const canvas = within(canvasElement.ownerDocument.body); + const closeButton = await canvas.findByRole('button', { + name: /^닫기$|^Close$/, + }); + const modal = canvas.getByRole('dialog'); + const image = canvas.getByRole('img', { + name: /업데이트 이미지|Update image/, + }); + await waitFor(() => expect(image.naturalWidth).toBeGreaterThan(0)); + await canvasElement.ownerDocument.fonts.ready; + + await step('화면 크기에 맞는 크기와 이미지 비율을 유지한다', async () => { + const { innerWidth, innerHeight } = window; + const isMobile = innerWidth < 768; + const expectedSize = isMobile + ? Math.min(innerWidth - 32, innerHeight - 32) + : Math.max( + 600, + Math.min( + innerWidth * 0.475, + innerHeight * (args.data.mode === 'predefined' ? 0.9 : 0.8), + 780, + ), + ); + const rect = modal.getBoundingClientRect(); + expect(Math.abs(rect.width - expectedSize)).toBeLessThanOrEqual(1); + if (isMobile || args.data.mode === 'predefined') { + expect(Math.abs(rect.height - expectedSize)).toBeLessThanOrEqual(1); + } else { + const imageRect = image.getBoundingClientRect(); + expect(Math.abs(imageRect.width - rect.width)).toBeLessThanOrEqual(1); + expect( + Math.abs( + imageRect.height - + (rect.width * image.naturalHeight) / image.naturalWidth, + ), + ).toBeLessThanOrEqual(1); + } + const outerRect = modal.parentElement!.getBoundingClientRect(); + expect(outerRect.width).toBeGreaterThanOrEqual(rect.width - 1); + if (isMobile) { + expect(rect.left).toBeGreaterThanOrEqual(15); + expect(rect.top).toBeGreaterThanOrEqual(15); + expect(innerWidth - rect.right).toBeGreaterThanOrEqual(15); + expect(innerHeight - rect.bottom).toBeGreaterThanOrEqual(15); + } + }); + + await step('이미지와 체크박스, 닫기 버튼이 겹치지 않는다', async () => { + const checkbox = canvas.getByRole('checkbox'); + const imageRect = image.getBoundingClientRect(); + const checkboxRect = checkbox.closest('label')!.getBoundingClientRect(); + const buttonRect = closeButton.getBoundingClientRect(); + const modalRect = modal.getBoundingClientRect(); + expect(imageRect.height).toBeGreaterThan(0); + expect(imageRect.bottom).toBeLessThanOrEqual(checkboxRect.top + 1); + expect(checkboxRect.bottom).toBeLessThanOrEqual(buttonRect.top + 1); + expect(buttonRect.bottom).toBeLessThanOrEqual(modalRect.bottom + 1); + expect(image).toHaveStyle({ + objectFit: + window.innerWidth < 768 || args.data.mode === 'predefined' + ? 'contain' + : 'fill', + }); + expect(canvas.getAllByRole('button')).toHaveLength(1); + }); + + const patchNote = args.data; + if (window.innerWidth < 768 && patchNote.mode === 'predefined') { + await step('긴 설명을 스크롤해 끝까지 읽을 수 있다', async () => { + const description = canvas.getByText( + parameters.language === 'en' + ? patchNote.descriptionEn + : patchNote.descriptionKo, + { normalizer: (value) => value }, + ); + const content = description.parentElement!; + const originalScrollTop = content.scrollTop; + try { + content.scrollTop = content.scrollHeight; + expect( + description.getBoundingClientRect().bottom, + ).toBeLessThanOrEqual(content.getBoundingClientRect().bottom + 1); + } finally { + content.scrollTop = originalScrollTop; + } + }); + } + }, }; export default meta; @@ -27,9 +188,93 @@ export const Default: Story = { imageKo: PatchNoteImageKorean, imageEn: PatchNoteImageEnglish, mode: 'predefined', - } as PredefinedPatchNoteData, + }, isChecked: false, - onChecked: () => {}, - onClose: () => {}, + onChecked: fn(), + onClose: fn(), }, }; + +function viewport(width: number, height: number) { + return { + viewport: { + viewports: { + patchNote: { + name: `${width} × ${height}`, + styles: { width: `${width}px`, height: `${height}px` }, + type: width < 768 ? 'mobile' : 'desktop', + }, + }, + defaultViewport: 'patchNote', + }, + }; +} + +export const Mobile: Story = { + args: { ...Default.args, data: LATEST_PATCH_NOTE }, + parameters: viewport(390, 844), +}; + +export const MobileEnglish: Story = { + ...Mobile, + parameters: { ...viewport(390, 844), language: 'en' }, +}; + +export const PredefinedMobile: Story = { + args: Default.args, + parameters: viewport(390, 844), +}; + +export const SmallMobileEnglish: Story = { + args: Default.args, + parameters: { ...viewport(320, 568), language: 'en' }, +}; + +export const LongContentMobileEnglish: Story = { + args: { + ...Default.args, + data: { + mode: 'predefined', + version: 'long-content', + imageKo: PatchNoteImageKorean, + imageEn: PatchNoteImageEnglish, + titleKo: '토론 종료 후 피드백과 투표 기능을 확인하세요', + titleEn: 'Explore feedback and voting improvements after every debate', + descriptionKo: + '새로운 피드백과 투표 기능을 사용해 보세요.\n토론 결과를 함께 확인하고 의견을 나눌 수 있어요.\n전체 업데이트 내용을 확인하세요.', + descriptionEn: + 'Try the new feedback and voting features after your debate.\nReview the results together and share your thoughts with everyone.\nRead the complete update and learn how to use each new feature.', + }, + }, + parameters: { ...viewport(320, 568), language: 'en' }, +}; + +export const MobileLandscape: Story = { + args: Mobile.args, + parameters: viewport(667, 375), +}; + +export const PredefinedLandscapeEnglish: Story = { + args: Default.args, + parameters: { ...viewport(667, 375), language: 'en' }, +}; + +export const MobileBoundary: Story = { + args: Mobile.args, + parameters: viewport(767, 900), +}; + +export const DesktopBoundary: Story = { + args: Mobile.args, + parameters: viewport(768, 900), +}; + +export const Desktop: Story = { + args: Mobile.args, + parameters: viewport(1440, 900), +}; + +export const PredefinedDesktop: Story = { + args: Default.args, + parameters: viewport(1440, 900), +}; diff --git a/src/components/UpdateModal/UpdateModal.test.tsx b/src/components/UpdateModal/UpdateModal.test.tsx index 6405289d..e993c4fe 100644 --- a/src/components/UpdateModal/UpdateModal.test.tsx +++ b/src/components/UpdateModal/UpdateModal.test.tsx @@ -124,6 +124,8 @@ describe('UpdateModal', () => { }) => { await renderUpdateModal(predefinedPatchNote, language); + expect(screen.getByRole('dialog', { name: title })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: title })).toBeInTheDocument(); expect(screen.getByRole('img', { name: imageAlt })).toHaveAttribute( 'src', image, @@ -145,6 +147,7 @@ describe('UpdateModal', () => { language: 'ko' as const, image: '/patch-note-only-ko.png', imageAlt: '업데이트 이미지', + dialogName: '디베이트 타이머에 새로운 기능이 생겼어요!', hideForWeek: '일주일간 보지 않기', closeButton: '닫기', }, @@ -152,14 +155,25 @@ describe('UpdateModal', () => { language: 'en' as const, image: '/patch-note-only-en.png', imageAlt: 'Update image', + dialogName: 'New features are now available in Debate Timer!', hideForWeek: "Don't show again for a week", closeButton: 'Close', }, ])( 'image-only 모드에서 $language 콘텐츠를 표시한다', - async ({ language, image, imageAlt, hideForWeek, closeButton }) => { + async ({ + language, + image, + imageAlt, + dialogName, + hideForWeek, + closeButton, + }) => { await renderUpdateModal(imageOnlyPatchNote, language); + expect( + screen.getByRole('dialog', { name: dialogName }), + ).toBeInTheDocument(); expect(screen.getByRole('img', { name: imageAlt })).toHaveAttribute( 'src', image, @@ -235,15 +249,14 @@ describe('UpdateModal', () => { expect(closeButton).toHaveClass('shrink-0'); }); - it('image-only 모드에서 정사각형 고정 없이 이미지가 모달 너비를 가득 채운다', async () => { + it('image-only 모드의 데스크탑 이미지는 모달 너비와 원래 비율을 유지한다', async () => { const { container } = await renderUpdateModal(imageOnlyPatchNote, 'ko'); const modal = container.firstElementChild; const image = screen.getByRole('img', { name: '업데이트 이미지' }); expect(modal).not.toHaveClass('aspect-square'); - expect(image).toHaveClass('block', 'h-auto', 'w-full'); - expect(image).not.toHaveClass('object-contain'); + expect(image).toHaveClass('block', 'w-full', 'md:h-auto', 'md:object-fill'); }); it('predefined 모드는 정사각형 레이아웃을 유지한다', async () => { diff --git a/src/components/UpdateModal/UpdateModal.tsx b/src/components/UpdateModal/UpdateModal.tsx index a74bb581..05ab96f8 100644 --- a/src/components/UpdateModal/UpdateModal.tsx +++ b/src/components/UpdateModal/UpdateModal.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next'; +import { useId } from 'react'; import MegaphoneAsset from './MegaphoneAsset'; import NoticeAsset from './NoticeAsset'; import { @@ -9,11 +10,28 @@ import { DEFAULT_LANG, isSupportedLang } from '../../util/languageRouting'; import DTCheck from '../icons/Check'; import clsx from 'clsx'; -// predefined 모드는 정사각형 비율 안에서 퍼센트 단위로 배치함 +// 모바일에서는 상하좌우 16px 여백 안에 정사각형 전체가 들어오도록 함. +const MOBILE_MODAL_CLASS = + 'size-[min(calc(100vw-32px),calc(100dvh-32px))] md:h-auto'; +// 데스크탑의 predefined 모드는 기존 정사각형 배치를 유지함. const PREDEFINED_MODAL_CLASS = - 'aspect-square w-[clamp(600px,min(47.5vw,90vh),780px)]'; -// image-only 모드는 이미지 비율대로 높이가 정해지므로 화면 높이를 넘지 않도록 너비를 제한함 -const IMAGE_ONLY_MODAL_CLASS = 'w-[clamp(600px,min(47.5vw,80vh),780px)]'; + 'aspect-square md:w-[clamp(600px,min(47.5vw,90vh),780px)]'; +// 데스크탑의 image-only 모드는 이미지의 원래 비율에 따라 높이가 결정됨. +const IMAGE_ONLY_MODAL_CLASS = 'md:w-[clamp(600px,min(47.5vw,80vh),780px)]'; +const MAIN_CONTENT_CLASS = + 'flex h-[59.5%] w-full shrink-0 flex-col gap-3 bg-[#EFF0F4] p-[4.5%] md:shrink md:gap-[clamp(36px,2.75vw,44px)]'; +const HEADER_CLASS = + 'flex h-12 w-full shrink-0 flex-row items-center gap-[1%] md:h-[clamp(72px,6vh,96px)] md:shrink'; +const HEADER_TEXT_CLASS = + 'flex min-w-0 w-full flex-col space-y-2 md:space-y-[clamp(15px,1.25vw,20px)]'; +const TEXT_CONTENT_CLASS = + 'flex min-h-0 flex-1 flex-col items-center overflow-y-auto md:h-full md:min-h-[auto] md:flex-[0_1_auto] md:justify-center md:overflow-visible'; +const IMAGE_ONLY_CONTENT_CLASS = + 'flex min-h-0 w-full flex-1 flex-col md:min-h-[auto] md:flex-[0_1_auto]'; +const IMAGE_ONLY_IMAGE_CONTAINER_CLASS = + 'min-h-0 w-full flex-1 overflow-hidden md:min-h-[auto] md:flex-[0_1_auto] md:overflow-visible'; +const CLOSE_BUTTON_CLASS = + 'flex shrink-0 flex-row items-center justify-center bg-brand transition-all hover:bg-brand-hover'; const HIDE_FOR_WEEK_LABEL_CLASS = 'group flex w-fit cursor-pointer select-none flex-row items-center gap-[clamp(6px,0.5vw,8px)]'; @@ -72,9 +90,11 @@ function HideForWeekCheckbox(props: HideForWeekCheckboxProps) { ); } +/** 모바일에서는 화면 여백 안에 정사각형으로, 데스크탑에서는 모드별 비율로 표시함. */ export default function UpdateModal(props: UpdateModalProps) { const { data, isChecked, onChecked, onClose } = props; const { t, i18n } = useTranslation(); + const titleId = useId(); const currentLang = i18n.resolvedLanguage ?? i18n.language; const primaryLang = currentLang?.split(/[-_]/)[0]; const lang = isSupportedLang(primaryLang) ? primaryLang : DEFAULT_LANG; @@ -84,21 +104,29 @@ export default function UpdateModal(props: UpdateModalProps) { return (
{isPredefined ? ( <> {/* 메인 컨텐츠 */} -
-
+
+
- +
-
+

{t('디베이트 타이머에 새로운 기능이 생겼어요!')}

@@ -119,14 +147,17 @@ export default function UpdateModal(props: UpdateModalProps) {
{/* 텍스트 컨텐츠 */} -
+
{/* 타이틀 및 내용 */} -
-

+

+

{isEnglish ? data.titleEn : data.titleKo} -

-
-

+

+
+

{isEnglish ? data.descriptionEn : data.descriptionKo}

@@ -136,18 +167,18 @@ export default function UpdateModal(props: UpdateModalProps) { id="update-modal-hide-for-week-predefined" isChecked={isChecked} onChecked={onChecked} - className="px-[2%] py-[1.6%]" + className="shrink-0 px-[2%] py-[1.6%] md:shrink" />
) : ( -
+
{/* 이미지 컨텐츠 */} -
+
{t('업데이트
@@ -165,7 +196,7 @@ export default function UpdateModal(props: UpdateModalProps) {