diff --git a/.scripts/commands/generateDocs/index.ts b/.scripts/commands/generateDocs/index.ts index ad3c5406..846937a6 100644 --- a/.scripts/commands/generateDocs/index.ts +++ b/.scripts/commands/generateDocs/index.ts @@ -40,6 +40,17 @@ export async function generateDocs(names: string[]) { ctx.docSource = docSource; }, }, + { + title: `Write English document`, + task: async ctx => { + const { docSource } = ctx; + const dirname = path.dirname(sourceFilePath); + + if (docSource != null) { + await fs.writeFile(`${dirname}/${name}.md`, docSource); + } + }, + }, { title: `Translate markdown to Korean`, task: async ctx => { @@ -48,37 +59,45 @@ export async function generateDocs(names: string[]) { let isFileExists = false; try { - await fs.access(`${dirname}/${name}.md`); await fs.access(`${dirname}/ko/${name}.md`); isFileExists = true; } catch { isFileExists = false; } - if (isFileExists && (await fs.readFile(`${dirname}/${name}.md`)).toString() === docSource) { - return; + // Skip if Korean file already exists and English file hasn't changed + if (isFileExists) { + try { + const existingEnglish = await fs.readFile(`${dirname}/${name}.md`, 'utf-8'); + if (existingEnglish === docSource) { + return; + } + } catch { + // Continue with translation if we can't read existing file + } } if (docSource == null) { throw new Error('docSource is not found'); } - const translatedDoc = await translate(docSource); - - ctx.translatedDoc = translatedDoc; + try { + const translatedDoc = await translate(docSource); + ctx.translatedDoc = translatedDoc; + } catch (error) { + // Log the error but don't fail the task - English doc is already saved + console.warn(`Translation failed for ${name}: ${error instanceof Error ? error.message : error}`); + ctx.translatedDoc = null; + } }, }, { - title: `Write document files`, + title: `Write Korean document`, + skip: ctx => ctx.translatedDoc == null, task: async ctx => { - const { docSource, translatedDoc } = ctx; - + const { translatedDoc } = ctx; const dirname = path.dirname(sourceFilePath); - if (docSource != null) { - await fs.writeFile(`${dirname}/${name}.md`, docSource); - } - if (translatedDoc != null) { await fs.mkdir(`${dirname}/ko`).catch(e => { if (e.code === 'EEXIST') { @@ -92,7 +111,7 @@ export async function generateDocs(names: string[]) { }, }, ], - { concurrent: false, ctx: subCtx } + { concurrent: false, ctx: subCtx, exitOnError: false } ), }, ]); @@ -108,7 +127,8 @@ function parseJSDoc(source: string) { const template = targetComment.tags.find(tag => tag.tag === 'template'); - const description = targetComment.tags.find(tag => tag.tag === 'description')?.description ?? ''; + const description = + targetComment.tags.find(tag => tag.tag === 'description')?.description ?? targetComment.description ?? ''; const params = targetComment.tags.filter(tag => tag.tag === 'param'); diff --git a/.scripts/commands/generateDocs/translate.ts b/.scripts/commands/generateDocs/translate.ts index f0b64667..1259c22f 100644 --- a/.scripts/commands/generateDocs/translate.ts +++ b/.scripts/commands/generateDocs/translate.ts @@ -284,7 +284,7 @@ ${origin} `; const response = await client.chat.completions.create({ - model: 'gpt-4o', + model: 'gpt-3.5-turbo', messages: [{ role: 'user', content: prompt }], response_format: { type: 'json_object' }, }); diff --git a/.scripts/index.ts b/.scripts/index.ts index 0d21f55a..0eda1b48 100644 --- a/.scripts/index.ts +++ b/.scripts/index.ts @@ -1,7 +1,13 @@ -import 'dotenv/config'; +import dotenv from 'dotenv'; +import path from 'path'; import { Command } from 'commander'; +import { getRootPath } from './utils/getRootPath.ts'; + +// Load .env from project root +dotenv.config({ path: path.join(getRootPath(), '.env') }); + import { generateDocs } from './commands/generateDocs/index.ts'; import { scaffold } from './commands/scaffold/index.ts'; diff --git a/.vitepress/config.mts b/.vitepress/config.mts index 8d6ed46b..08d42150 100644 --- a/.vitepress/config.mts +++ b/.vitepress/config.mts @@ -51,6 +51,10 @@ export default defineConfig({ 'packages/mobile/src/hooks/:hook/:hook.md': 'mobile/hooks/:hook.md', 'packages/mobile/src/hooks/:hook/ko/:hook.md': 'ko/mobile/hooks/:hook.md', + // Mobile utils + 'packages/mobile/src/utils/:util/:util.md': 'mobile/utils/:util.md', + 'packages/mobile/src/utils/:util/ko/:util.md': 'ko/mobile/utils/:util.md', + // Mobile keyboardHeight (special case - folder name differs from hook name) 'packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.md': 'mobile/hooks/useKeyboardHeight.md', 'packages/mobile/src/hooks/keyboardHeight/ko/useKeyboardHeight.md': 'ko/mobile/hooks/useKeyboardHeight.md', diff --git a/.vitepress/en.mts b/.vitepress/en.mts index 255b4a15..cd7f1d17 100644 --- a/.vitepress/en.mts +++ b/.vitepress/en.mts @@ -89,6 +89,11 @@ function mobileSidebar(): DefaultTheme.SidebarItem[] { collapsed: false, items: getSidebarItems(mobilePackageRoot, 'hooks', '/mobile'), }, + { + text: 'Utils', + collapsed: false, + items: getSidebarItems(mobilePackageRoot, 'utils', '/mobile'), + }, ], }, ]; diff --git a/.vitepress/ko.mts b/.vitepress/ko.mts index d9c1cb70..f35846b0 100644 --- a/.vitepress/ko.mts +++ b/.vitepress/ko.mts @@ -89,6 +89,11 @@ function mobileSidebar(): DefaultTheme.SidebarItem[] { collapsed: false, items: getSidebarItems(mobilePackageRoot, 'hooks', '/mobile', 'ko'), }, + { + text: '유틸리티', + collapsed: false, + items: getSidebarItems(mobilePackageRoot, 'utils', '/mobile', 'ko'), + }, ], }, ]; diff --git a/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.test.ts b/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.test.ts index e5f72fe9..55116e03 100644 --- a/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.test.ts +++ b/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.test.ts @@ -1,11 +1,11 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { subscribeKeyboardHeight } from '../../utils/keyboard/subscribeKeyboardHeight.ts'; +import { subscribeKeyboardHeight } from '../../utils/subscribeKeyboardHeight/index.ts'; import { useKeyboardHeight } from './useKeyboardHeight.ts'; -vi.mock('../../utils/keyboard/subscribeKeyboardHeight.ts', () => ({ +vi.mock('../../utils/subscribeKeyboardHeight/index.ts', () => ({ subscribeKeyboardHeight: vi.fn(), })); diff --git a/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.ts b/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.ts index dc48d907..7ff5571f 100644 --- a/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.ts +++ b/packages/mobile/src/hooks/keyboardHeight/useKeyboardHeight.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { subscribeKeyboardHeight } from '../../utils/keyboard/subscribeKeyboardHeight.ts'; +import { subscribeKeyboardHeight } from '../../utils/subscribeKeyboardHeight/index.ts'; type UseKeyboardHeightOptions = { /** diff --git a/packages/mobile/src/hooks/useBodyScrollLock/useBodyScrollLock.ts b/packages/mobile/src/hooks/useBodyScrollLock/useBodyScrollLock.ts index 6a961e24..2428e79e 100644 --- a/packages/mobile/src/hooks/useBodyScrollLock/useBodyScrollLock.ts +++ b/packages/mobile/src/hooks/useBodyScrollLock/useBodyScrollLock.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react'; -import { disableBodyScrollLock, enableBodyScrollLock } from '../../utils/bodyScrollLock.ts'; +import { disableBodyScrollLock } from '../../utils/disableBodyScrollLock/index.ts'; +import { enableBodyScrollLock } from '../../utils/enableBodyScrollLock/index.ts'; /** * Hook to lock body scroll diff --git a/packages/mobile/src/hooks/useNetworkStatus/useNetworkStatus.ts b/packages/mobile/src/hooks/useNetworkStatus/useNetworkStatus.ts index 99a179ea..ce4fd5ee 100644 --- a/packages/mobile/src/hooks/useNetworkStatus/useNetworkStatus.ts +++ b/packages/mobile/src/hooks/useNetworkStatus/useNetworkStatus.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { isServer } from '../../utils/isServer.ts'; +import { isServer } from '../../utils/isServer/index.ts'; /** * Effective connection type based on Network Information API diff --git a/packages/mobile/src/hooks/usePageVisibility/usePageVisibility.ts b/packages/mobile/src/hooks/usePageVisibility/usePageVisibility.ts index e53a94f2..b908c497 100644 --- a/packages/mobile/src/hooks/usePageVisibility/usePageVisibility.ts +++ b/packages/mobile/src/hooks/usePageVisibility/usePageVisibility.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { isServer } from '../../utils/isServer.ts'; +import { isServer } from '../../utils/isServer/index.ts'; /** * Page visibility state derived from browser's DocumentVisibilityState. diff --git a/packages/mobile/src/hooks/useSafeAreaInset/useSafeAreaInset.ts b/packages/mobile/src/hooks/useSafeAreaInset/useSafeAreaInset.ts index 47ee734d..da5f9e62 100644 --- a/packages/mobile/src/hooks/useSafeAreaInset/useSafeAreaInset.ts +++ b/packages/mobile/src/hooks/useSafeAreaInset/useSafeAreaInset.ts @@ -1,7 +1,7 @@ import { startTransition, useCallback, useEffect, useState } from 'react'; -import { isServer } from '../../utils/isServer.ts'; -import { getSafeAreaInset, type SafeAreaInset } from '../../utils/safeArea/getSafeAreaInset.ts'; +import { getSafeAreaInset, type SafeAreaInset } from '../../utils/getSafeAreaInset/index.ts'; +import { isServer } from '../../utils/isServer/index.ts'; /** * React hook to track safe area inset changes diff --git a/packages/mobile/src/hooks/useScrollDirection/useScrollDirection.ts b/packages/mobile/src/hooks/useScrollDirection/useScrollDirection.ts index 147b49b9..2c11d78a 100644 --- a/packages/mobile/src/hooks/useScrollDirection/useScrollDirection.ts +++ b/packages/mobile/src/hooks/useScrollDirection/useScrollDirection.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { isServer } from '../../utils/isServer.ts'; +import { isServer } from '../../utils/isServer/index.ts'; type ScrollDirection = 'up' | 'down' | null; diff --git a/packages/mobile/src/hooks/useVisualViewport/useVisualViewport.ts b/packages/mobile/src/hooks/useVisualViewport/useVisualViewport.ts index c08402a5..56bfbdae 100644 --- a/packages/mobile/src/hooks/useVisualViewport/useVisualViewport.ts +++ b/packages/mobile/src/hooks/useVisualViewport/useVisualViewport.ts @@ -1,6 +1,6 @@ import { startTransition, useCallback, useEffect, useState } from 'react'; -import { isServer } from '../../utils/isServer.ts'; +import { isServer } from '../../utils/isServer/index.ts'; type VisualViewportState = { /** Viewport width (px) */ diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts index 02ad0fe6..4d135e82 100644 --- a/packages/mobile/src/index.ts +++ b/packages/mobile/src/index.ts @@ -13,10 +13,13 @@ export { useScrollDirection } from './hooks/useScrollDirection/index.ts'; export { useVisualViewport } from './hooks/useVisualViewport/index.ts'; // Utils -export { disableBodyScrollLock, enableBodyScrollLock } from './utils/bodyScrollLock.ts'; -export { isAndroid, isIOS } from './utils/device/device.ts'; -export { isServer } from './utils/isServer.ts'; -export { getKeyboardHeight } from './utils/keyboard/getKeyboardHeight.ts'; -export { isKeyboardVisible } from './utils/keyboard/isKeyboardVisible.ts'; -export { subscribeKeyboardHeight } from './utils/keyboard/subscribeKeyboardHeight.ts'; -export { getSafeAreaInset } from './utils/safeArea/getSafeAreaInset.ts'; +export { disableBodyScrollLock } from './utils/disableBodyScrollLock/index.ts'; +export { enableBodyScrollLock } from './utils/enableBodyScrollLock/index.ts'; +export { getKeyboardHeight } from './utils/getKeyboardHeight/index.ts'; +export type { SafeAreaInset } from './utils/getSafeAreaInset/index.ts'; +export { getSafeAreaInset } from './utils/getSafeAreaInset/index.ts'; +export { isAndroid } from './utils/isAndroid/index.ts'; +export { isIOS } from './utils/isIOS/index.ts'; +export { isKeyboardVisible } from './utils/isKeyboardVisible/index.ts'; +export { isServer } from './utils/isServer/index.ts'; +export { subscribeKeyboardHeight } from './utils/subscribeKeyboardHeight/index.ts'; diff --git a/packages/mobile/src/utils/bodyScrollLock.test.ts b/packages/mobile/src/utils/bodyScrollLock.test.ts deleted file mode 100644 index 23b03733..00000000 --- a/packages/mobile/src/utils/bodyScrollLock.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { disableBodyScrollLock, enableBodyScrollLock } from './bodyScrollLock.ts'; - -describe('bodyScrollLock', () => { - const SCROLL_POSITION_ATTR = 'data-simplikit-scroll-y'; - - beforeEach(() => { - document.body.style.cssText = ''; - document.body.removeAttribute(SCROLL_POSITION_ATTR); - - // Mock window.scrollY - Object.defineProperty(window, 'scrollY', { - writable: true, - configurable: true, - value: 0, - }); - - // Mock window.scrollTo - window.scrollTo = vi.fn(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('enableBodyScrollLock', () => { - it('should set body styles for scroll lock', () => { - enableBodyScrollLock(); - - expect(document.body.style.overflow).toBe('hidden'); - expect(document.body.style.position).toBe('fixed'); - expect(document.body.style.left).toBe('0px'); - expect(document.body.style.right).toBe('0px'); - expect(document.body.style.bottom).toBe('0px'); - }); - - it('should save current scroll position', () => { - Object.defineProperty(window, 'scrollY', { value: 500 }); - - enableBodyScrollLock(); - - expect(document.body.getAttribute(SCROLL_POSITION_ATTR)).toBe('500'); - expect(document.body.style.top).toBe('-500px'); - }); - - it('should not apply lock twice (prevent duplicate calls)', () => { - Object.defineProperty(window, 'scrollY', { value: 100 }); - enableBodyScrollLock(); - - // Change scroll position and call again - Object.defineProperty(window, 'scrollY', { value: 200 }); - enableBodyScrollLock(); - - // Should still have the first scroll position - expect(document.body.getAttribute(SCROLL_POSITION_ATTR)).toBe('100'); - expect(document.body.style.top).toBe('-100px'); - }); - }); - - describe('disableBodyScrollLock', () => { - it('should restore body styles after unlock', () => { - enableBodyScrollLock(); - disableBodyScrollLock(); - - expect(document.body.style.overflow).toBe(''); - expect(document.body.style.position).toBe(''); - expect(document.body.style.top).toBe(''); - expect(document.body.style.left).toBe(''); - expect(document.body.style.right).toBe(''); - expect(document.body.style.bottom).toBe(''); - }); - - it('should restore scroll position', () => { - Object.defineProperty(window, 'scrollY', { value: 300 }); - - enableBodyScrollLock(); - disableBodyScrollLock(); - - expect(window.scrollTo).toHaveBeenCalledWith(0, 300); - }); - - it('should remove scroll position attribute', () => { - enableBodyScrollLock(); - disableBodyScrollLock(); - - expect(document.body.getAttribute(SCROLL_POSITION_ATTR)).toBeNull(); - }); - - it('should do nothing if not locked', () => { - disableBodyScrollLock(); - - expect(window.scrollTo).not.toHaveBeenCalled(); - }); - }); - - describe('lock/unlock cycle', () => { - it('should handle multiple lock/unlock cycles correctly', () => { - Object.defineProperty(window, 'scrollY', { value: 100 }); - - // First cycle - enableBodyScrollLock(); - expect(document.body.style.position).toBe('fixed'); - disableBodyScrollLock(); - expect(document.body.style.position).toBe(''); - - // Second cycle with different scroll position - Object.defineProperty(window, 'scrollY', { value: 200 }); - enableBodyScrollLock(); - expect(document.body.getAttribute(SCROLL_POSITION_ATTR)).toBe('200'); - disableBodyScrollLock(); - expect(window.scrollTo).toHaveBeenLastCalledWith(0, 200); - }); - }); - - describe('edge cases', () => { - it('should handle zero scroll position', () => { - Object.defineProperty(window, 'scrollY', { value: 0 }); - - enableBodyScrollLock(); - - // Note: `-0px` and `0px` are functionally equivalent - expect(['0px', '-0px']).toContain(document.body.style.top); - expect(document.body.getAttribute(SCROLL_POSITION_ATTR)).toBe('0'); - }); - - it('should handle large scroll position', () => { - Object.defineProperty(window, 'scrollY', { value: 999999 }); - - enableBodyScrollLock(); - - expect(document.body.style.top).toBe('-999999px'); - expect(document.body.getAttribute(SCROLL_POSITION_ATTR)).toBe('999999'); - }); - - it('should handle negative scroll position gracefully', () => { - Object.defineProperty(window, 'scrollY', { value: -100 }); - - enableBodyScrollLock(); - disableBodyScrollLock(); - - expect(window.scrollTo).toHaveBeenCalledWith(0, -100); - }); - - it('should handle invalid scroll position with NaN', () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - enableBodyScrollLock(); - // Manually corrupt the saved scroll position - document.body.setAttribute(SCROLL_POSITION_ATTR, 'invalid'); - disableBodyScrollLock(); - - expect(consoleWarnSpy).toHaveBeenCalledWith('[@react-simplikit/mobile] Invalid scroll position, defaulting to 0'); - expect(window.scrollTo).toHaveBeenCalledWith(0, 0); - - consoleWarnSpy.mockRestore(); - }); - }); -}); diff --git a/packages/mobile/src/utils/device/device.test.ts b/packages/mobile/src/utils/device/device.test.ts deleted file mode 100644 index a316d609..00000000 --- a/packages/mobile/src/utils/device/device.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { isAndroid, isIOS } from './device.ts'; - -describe('device utils', () => { - it('should detect iOS', () => { - expect(isIOS('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)')).toBe(true); - }); - - it('should detect iPadOS (MacIntel + touch)', () => { - Object.defineProperty(navigator, 'platform', { - value: 'MacIntel', - configurable: true, - }); - Object.defineProperty(navigator, 'maxTouchPoints', { - value: 5, - configurable: true, - }); - - expect(isIOS('Mozilla/5.0 (Macintosh; Intel Mac OS X)')).toBe(true); - }); - - it('should detect Android', () => { - expect(isAndroid('Mozilla/5.0 (Linux; Android 12; Pixel 6) Chrome/120')).toBe(true); - }); -}); diff --git a/packages/mobile/src/utils/disableBodyScrollLock/disableBodyScrollLock.md b/packages/mobile/src/utils/disableBodyScrollLock/disableBodyScrollLock.md new file mode 100644 index 00000000..57c39348 --- /dev/null +++ b/packages/mobile/src/utils/disableBodyScrollLock/disableBodyScrollLock.md @@ -0,0 +1,25 @@ +# disableBodyScrollLock + +`disableBodyScrollLock` is a utility function that unlocks the body scroll. It restores the scroll locked by `enableBodyScrollLock` and returns to the saved scroll position. Safe to call in SSR environment (no-op on server). Safe to call even if not locked (no-op). + +## Interface + +```ts +function disableBodyScrollLock(): void; +``` + +### Parameters + +### Return Value + + + +## Example + +```tsx +// When modal opens +enableBodyScrollLock(); + +// When modal closes +disableBodyScrollLock(); +``` diff --git a/packages/mobile/src/utils/bodyScrollLock.ssr.test.ts b/packages/mobile/src/utils/disableBodyScrollLock/disableBodyScrollLock.ssr.test.ts similarity index 52% rename from packages/mobile/src/utils/bodyScrollLock.ssr.test.ts rename to packages/mobile/src/utils/disableBodyScrollLock/disableBodyScrollLock.ssr.test.ts index 13ddb721..0fd8cf59 100644 --- a/packages/mobile/src/utils/bodyScrollLock.ssr.test.ts +++ b/packages/mobile/src/utils/disableBodyScrollLock/disableBodyScrollLock.ssr.test.ts @@ -5,15 +5,9 @@ */ import { describe, expect, it } from 'vitest'; -import { disableBodyScrollLock, enableBodyScrollLock } from './bodyScrollLock.ts'; - -describe('bodyScrollLock SSR environment', () => { - it('should do nothing when enableBodyScrollLock is called on server', () => { - // In Node environment, window is undefined - expect(typeof window).toBe('undefined'); - expect(() => enableBodyScrollLock()).not.toThrow(); - }); +import { disableBodyScrollLock } from './disableBodyScrollLock.ts'; +describe('disableBodyScrollLock SSR environment', () => { it('should do nothing when disableBodyScrollLock is called on server', () => { // In Node environment, window is undefined expect(typeof window).toBe('undefined'); diff --git a/packages/mobile/src/utils/disableBodyScrollLock/disableBodyScrollLock.test.ts b/packages/mobile/src/utils/disableBodyScrollLock/disableBodyScrollLock.test.ts new file mode 100644 index 00000000..5c8b63fc --- /dev/null +++ b/packages/mobile/src/utils/disableBodyScrollLock/disableBodyScrollLock.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { enableBodyScrollLock } from '../enableBodyScrollLock/index.ts'; + +import { disableBodyScrollLock } from './disableBodyScrollLock.ts'; + +describe('disableBodyScrollLock', () => { + const SCROLL_POSITION_ATTR = 'data-simplikit-scroll-y'; + + beforeEach(() => { + document.body.style.cssText = ''; + document.body.removeAttribute(SCROLL_POSITION_ATTR); + + // Mock window.scrollY + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: 0, + }); + + // Mock window.scrollTo + window.scrollTo = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should restore body styles after unlock', () => { + enableBodyScrollLock(); + disableBodyScrollLock(); + + expect(document.body.style.overflow).toBe(''); + expect(document.body.style.position).toBe(''); + expect(document.body.style.top).toBe(''); + expect(document.body.style.left).toBe(''); + expect(document.body.style.right).toBe(''); + expect(document.body.style.bottom).toBe(''); + }); + + it('should restore scroll position', () => { + Object.defineProperty(window, 'scrollY', { value: 300 }); + + enableBodyScrollLock(); + disableBodyScrollLock(); + + expect(window.scrollTo).toHaveBeenCalledWith(0, 300); + }); + + it('should remove scroll position attribute', () => { + enableBodyScrollLock(); + disableBodyScrollLock(); + + expect(document.body.getAttribute(SCROLL_POSITION_ATTR)).toBeNull(); + }); + + it('should do nothing if not locked', () => { + disableBodyScrollLock(); + + expect(window.scrollTo).not.toHaveBeenCalled(); + }); + + describe('edge cases', () => { + it('should handle negative scroll position gracefully', () => { + Object.defineProperty(window, 'scrollY', { value: -100 }); + + enableBodyScrollLock(); + disableBodyScrollLock(); + + expect(window.scrollTo).toHaveBeenCalledWith(0, -100); + }); + + it('should handle invalid scroll position with NaN', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + enableBodyScrollLock(); + // Manually corrupt the saved scroll position + document.body.setAttribute(SCROLL_POSITION_ATTR, 'invalid'); + disableBodyScrollLock(); + + expect(consoleWarnSpy).toHaveBeenCalledWith('[@react-simplikit/mobile] Invalid scroll position, defaulting to 0'); + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + + consoleWarnSpy.mockRestore(); + }); + }); +}); diff --git a/packages/mobile/src/utils/bodyScrollLock.ts b/packages/mobile/src/utils/disableBodyScrollLock/disableBodyScrollLock.ts similarity index 52% rename from packages/mobile/src/utils/bodyScrollLock.ts rename to packages/mobile/src/utils/disableBodyScrollLock/disableBodyScrollLock.ts index 419bcfa7..f5987865 100644 --- a/packages/mobile/src/utils/bodyScrollLock.ts +++ b/packages/mobile/src/utils/disableBodyScrollLock/disableBodyScrollLock.ts @@ -1,4 +1,4 @@ -import { isServer } from './isServer.ts'; +import { isServer } from '../isServer/index.ts'; /** * Data attribute key for storing scroll position @@ -6,48 +6,21 @@ import { isServer } from './isServer.ts'; const SCROLL_POSITION_ATTR = 'data-simplikit-scroll-y'; /** - * Lock body scroll + * @description + * `disableBodyScrollLock` is a utility function that unlocks the body scroll. + * It restores the scroll locked by `enableBodyScrollLock` and returns to the saved scroll position. * - * Prevents the body from scrolling by applying fixed positioning. * Safe to call in SSR environment (no-op on server). - * Calling multiple times has no effect until unlocked. + * Safe to call even if not locked (no-op). + * + * @returns {void} * * @example - * ```ts * // When modal opens * enableBodyScrollLock(); * * // When modal closes * disableBodyScrollLock(); - * ``` - */ -export function enableBodyScrollLock(): void { - if (isServer()) { - return; - } - - if (isBodyScrollLocked()) { - return; - } - - const scrollY = window.scrollY; - saveScrollPosition(scrollY); - applyScrollLockStyles(scrollY); -} - -/** - * Unlock body scroll - * - * Unlocks the scroll locked by enableBodyScrollLock() and - * restores the saved scroll position. - * Safe to call in SSR environment (no-op on server). - * Safe to call even if not locked (no-op). - * - * @example - * ```ts - * // When modal closes - * disableBodyScrollLock(); - * ``` */ export function disableBodyScrollLock(): void { if (isServer()) { @@ -65,24 +38,6 @@ export function disableBodyScrollLock(): void { clearSavedScrollPosition(); } -function isBodyScrollLocked(): boolean { - return document.body.getAttribute(SCROLL_POSITION_ATTR) != null; -} - -function saveScrollPosition(scrollY: number): void { - document.body.setAttribute(SCROLL_POSITION_ATTR, scrollY.toString()); -} - -function applyScrollLockStyles(scrollY: number): void { - const { body } = document; - body.style.overflow = 'hidden'; - body.style.position = 'fixed'; - body.style.top = `-${scrollY}px`; - body.style.left = '0px'; - body.style.right = '0px'; - body.style.bottom = '0px'; -} - function removeScrollLockStyles(): void { const { body } = document; body.style.removeProperty('overflow'); diff --git a/packages/mobile/src/utils/disableBodyScrollLock/index.ts b/packages/mobile/src/utils/disableBodyScrollLock/index.ts new file mode 100644 index 00000000..7311a34a --- /dev/null +++ b/packages/mobile/src/utils/disableBodyScrollLock/index.ts @@ -0,0 +1 @@ +export { disableBodyScrollLock } from './disableBodyScrollLock.ts'; diff --git a/packages/mobile/src/utils/disableBodyScrollLock/ko/disableBodyScrollLock.md b/packages/mobile/src/utils/disableBodyScrollLock/ko/disableBodyScrollLock.md new file mode 100644 index 00000000..e95564b1 --- /dev/null +++ b/packages/mobile/src/utils/disableBodyScrollLock/ko/disableBodyScrollLock.md @@ -0,0 +1,25 @@ +# disableBodyScrollLock + +`disableBodyScrollLock`는 body 스크롤을 잠금 해제하는 유틸리티 함수예요. `enableBodyScrollLock`에 의해 잠겨진 스크롤을 복원하고 저장된 스크롤 위치로 되돌아가요. SSR 환경에서 호출할 때 안전해요 (서버에서는 작동하지 않음). 잠겨 있지 않아도 호출해도 안전해요요. + +## 인터페이스 + +```ts +function disableBodyScrollLock(): void; +``` + +### Parameters + +### 반환 값 + + + +## 예시 + +```tsx +// 모달이 열릴 때 +enableBodyScrollLock(); + +// 모달이 닫힐 때 +disableBodyScrollLock(); +``` diff --git a/packages/mobile/src/utils/enableBodyScrollLock/enableBodyScrollLock.md b/packages/mobile/src/utils/enableBodyScrollLock/enableBodyScrollLock.md new file mode 100644 index 00000000..520662a4 --- /dev/null +++ b/packages/mobile/src/utils/enableBodyScrollLock/enableBodyScrollLock.md @@ -0,0 +1,25 @@ +# enableBodyScrollLock + +`enableBodyScrollLock` is a utility function that locks the body scroll. It prevents the body from scrolling by applying fixed positioning. This is useful when opening modals, drawers, or other overlay components. Safe to call in SSR environment (no-op on server). Calling multiple times has no effect until unlocked. + +## Interface + +```ts +function enableBodyScrollLock(): void; +``` + +### Parameters + +### Return Value + + + +## Example + +```tsx +// When modal opens +enableBodyScrollLock(); + +// When modal closes +disableBodyScrollLock(); +``` diff --git a/packages/mobile/src/utils/enableBodyScrollLock/enableBodyScrollLock.ssr.test.ts b/packages/mobile/src/utils/enableBodyScrollLock/enableBodyScrollLock.ssr.test.ts new file mode 100644 index 00000000..ba9a1c9a --- /dev/null +++ b/packages/mobile/src/utils/enableBodyScrollLock/enableBodyScrollLock.ssr.test.ts @@ -0,0 +1,16 @@ +/** + * @vitest-environment node + * + * SSR environment tests - runs in Node.js where window is truly undefined + */ +import { describe, expect, it } from 'vitest'; + +import { enableBodyScrollLock } from './enableBodyScrollLock.ts'; + +describe('enableBodyScrollLock SSR environment', () => { + it('should do nothing when enableBodyScrollLock is called on server', () => { + // In Node environment, window is undefined + expect(typeof window).toBe('undefined'); + expect(() => enableBodyScrollLock()).not.toThrow(); + }); +}); diff --git a/packages/mobile/src/utils/enableBodyScrollLock/enableBodyScrollLock.test.ts b/packages/mobile/src/utils/enableBodyScrollLock/enableBodyScrollLock.test.ts new file mode 100644 index 00000000..1f636842 --- /dev/null +++ b/packages/mobile/src/utils/enableBodyScrollLock/enableBodyScrollLock.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { disableBodyScrollLock } from '../disableBodyScrollLock/index.ts'; + +import { enableBodyScrollLock } from './enableBodyScrollLock.ts'; + +describe('enableBodyScrollLock', () => { + const SCROLL_POSITION_ATTR = 'data-simplikit-scroll-y'; + + beforeEach(() => { + document.body.style.cssText = ''; + document.body.removeAttribute(SCROLL_POSITION_ATTR); + + // Mock window.scrollY + Object.defineProperty(window, 'scrollY', { + writable: true, + configurable: true, + value: 0, + }); + + // Mock window.scrollTo + window.scrollTo = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should set body styles for scroll lock', () => { + enableBodyScrollLock(); + + expect(document.body.style.overflow).toBe('hidden'); + expect(document.body.style.position).toBe('fixed'); + expect(document.body.style.left).toBe('0px'); + expect(document.body.style.right).toBe('0px'); + expect(document.body.style.bottom).toBe('0px'); + }); + + it('should save current scroll position', () => { + Object.defineProperty(window, 'scrollY', { value: 500 }); + + enableBodyScrollLock(); + + expect(document.body.getAttribute(SCROLL_POSITION_ATTR)).toBe('500'); + expect(document.body.style.top).toBe('-500px'); + }); + + it('should not apply lock twice (prevent duplicate calls)', () => { + Object.defineProperty(window, 'scrollY', { value: 100 }); + enableBodyScrollLock(); + + // Change scroll position and call again + Object.defineProperty(window, 'scrollY', { value: 200 }); + enableBodyScrollLock(); + + // Should still have the first scroll position + expect(document.body.getAttribute(SCROLL_POSITION_ATTR)).toBe('100'); + expect(document.body.style.top).toBe('-100px'); + }); + + describe('lock/unlock cycle', () => { + it('should handle multiple lock/unlock cycles correctly', () => { + Object.defineProperty(window, 'scrollY', { value: 100 }); + + // First cycle + enableBodyScrollLock(); + expect(document.body.style.position).toBe('fixed'); + disableBodyScrollLock(); + expect(document.body.style.position).toBe(''); + + // Second cycle with different scroll position + Object.defineProperty(window, 'scrollY', { value: 200 }); + enableBodyScrollLock(); + expect(document.body.getAttribute(SCROLL_POSITION_ATTR)).toBe('200'); + disableBodyScrollLock(); + expect(window.scrollTo).toHaveBeenLastCalledWith(0, 200); + }); + }); + + describe('edge cases', () => { + it('should handle zero scroll position', () => { + Object.defineProperty(window, 'scrollY', { value: 0 }); + + enableBodyScrollLock(); + + // Note: `-0px` and `0px` are functionally equivalent + expect(['0px', '-0px']).toContain(document.body.style.top); + expect(document.body.getAttribute(SCROLL_POSITION_ATTR)).toBe('0'); + }); + + it('should handle large scroll position', () => { + Object.defineProperty(window, 'scrollY', { value: 999999 }); + + enableBodyScrollLock(); + + expect(document.body.style.top).toBe('-999999px'); + expect(document.body.getAttribute(SCROLL_POSITION_ATTR)).toBe('999999'); + }); + }); +}); diff --git a/packages/mobile/src/utils/enableBodyScrollLock/enableBodyScrollLock.ts b/packages/mobile/src/utils/enableBodyScrollLock/enableBodyScrollLock.ts new file mode 100644 index 00000000..2a57c08d --- /dev/null +++ b/packages/mobile/src/utils/enableBodyScrollLock/enableBodyScrollLock.ts @@ -0,0 +1,56 @@ +import { isServer } from '../isServer/index.ts'; + +/** + * Data attribute key for storing scroll position + */ +const SCROLL_POSITION_ATTR = 'data-simplikit-scroll-y'; + +/** + * @description + * `enableBodyScrollLock` is a utility function that locks the body scroll. + * It prevents the body from scrolling by applying fixed positioning. + * This is useful when opening modals, drawers, or other overlay components. + * + * Safe to call in SSR environment (no-op on server). + * Calling multiple times has no effect until unlocked. + * + * @returns {void} + * + * @example + * // When modal opens + * enableBodyScrollLock(); + * + * // When modal closes + * disableBodyScrollLock(); + */ +export function enableBodyScrollLock(): void { + if (isServer()) { + return; + } + + if (isBodyScrollLocked()) { + return; + } + + const scrollY = window.scrollY; + saveScrollPosition(scrollY); + applyScrollLockStyles(scrollY); +} + +function isBodyScrollLocked(): boolean { + return document.body.getAttribute(SCROLL_POSITION_ATTR) != null; +} + +function saveScrollPosition(scrollY: number): void { + document.body.setAttribute(SCROLL_POSITION_ATTR, scrollY.toString()); +} + +function applyScrollLockStyles(scrollY: number): void { + const { body } = document; + body.style.overflow = 'hidden'; + body.style.position = 'fixed'; + body.style.top = `-${scrollY}px`; + body.style.left = '0px'; + body.style.right = '0px'; + body.style.bottom = '0px'; +} diff --git a/packages/mobile/src/utils/enableBodyScrollLock/index.ts b/packages/mobile/src/utils/enableBodyScrollLock/index.ts new file mode 100644 index 00000000..c33200a1 --- /dev/null +++ b/packages/mobile/src/utils/enableBodyScrollLock/index.ts @@ -0,0 +1 @@ +export { enableBodyScrollLock } from './enableBodyScrollLock.ts'; diff --git a/packages/mobile/src/utils/enableBodyScrollLock/ko/enableBodyScrollLock.md b/packages/mobile/src/utils/enableBodyScrollLock/ko/enableBodyScrollLock.md new file mode 100644 index 00000000..a7035848 --- /dev/null +++ b/packages/mobile/src/utils/enableBodyScrollLock/ko/enableBodyScrollLock.md @@ -0,0 +1,25 @@ +# enableBodyScrollLock + +`enableBodyScrollLock`은 body 스크롤을 잠그는 유틸리티 함수예요. 고정 위치를 적용해서 body가 스크롤되는 것을 막아요. 모달, 서랍 또는 다른 오버레이 컴포넌트를 열 때 유용해요. SSR 환경에서 안전하게 호출할 수 있어요 (서버에서는 작동하지 않아요). 여러 번 호출해도 잠금 해제되기 전까지는 효과가 없어요. + +## 인터페이스 + +```ts +function enableBodyScrollLock(): void; +``` + +### Parameters + +### 반환 값 + + + +## 예시 + +```tsx +// 모달이 열릴 때 +enableBodyScrollLock(); + +// 모달이 닫힐 때 +disableBodyScrollLock(); +``` diff --git a/packages/mobile/src/utils/getKeyboardHeight/getKeyboardHeight.md b/packages/mobile/src/utils/getKeyboardHeight/getKeyboardHeight.md new file mode 100644 index 00000000..fdf21383 --- /dev/null +++ b/packages/mobile/src/utils/getKeyboardHeight/getKeyboardHeight.md @@ -0,0 +1,29 @@ +# getKeyboardHeight + +`getKeyboardHeight` is a utility function that returns the current on-screen keyboard height in pixels. This function uses the Visual Viewport API to calculate the keyboard height. It assumes a modern environment where Visual Viewport is supported (Safari / WKWebView 14+, Chrome / Android WebView 80+). The keyboard height is computed as: `window.innerHeight - visualViewport.height - visualViewport.offsetTop` The subtraction of `offsetTop` is required to correctly handle iOS behavior where the visual viewport may shift vertically when the keyboard appears. + +## Interface + +```ts +function getKeyboardHeight(): number; +``` + +### Parameters + +### Return Value + + + +## Example + +```tsx +const height = getKeyboardHeight(); + +if (height > 0) { + footer.style.paddingBottom = `${height}px`; +} +``` diff --git a/packages/mobile/src/utils/getKeyboardHeight/getKeyboardHeight.ssr.test.ts b/packages/mobile/src/utils/getKeyboardHeight/getKeyboardHeight.ssr.test.ts new file mode 100644 index 00000000..f41bb6a8 --- /dev/null +++ b/packages/mobile/src/utils/getKeyboardHeight/getKeyboardHeight.ssr.test.ts @@ -0,0 +1,15 @@ +/** + * @vitest-environment node + * + * SSR environment tests - runs in Node.js where window is truly undefined + */ +import { describe, expect, it } from 'vitest'; + +import { getKeyboardHeight } from './getKeyboardHeight.ts'; + +describe('getKeyboardHeight SSR environment', () => { + it('should return 0 in SSR environment', () => { + expect(typeof window).toBe('undefined'); + expect(getKeyboardHeight()).toBe(0); + }); +}); diff --git a/packages/mobile/src/utils/keyboard/getKeyboardHeight.test.ts b/packages/mobile/src/utils/getKeyboardHeight/getKeyboardHeight.test.ts similarity index 100% rename from packages/mobile/src/utils/keyboard/getKeyboardHeight.test.ts rename to packages/mobile/src/utils/getKeyboardHeight/getKeyboardHeight.test.ts diff --git a/packages/mobile/src/utils/keyboard/getKeyboardHeight.ts b/packages/mobile/src/utils/getKeyboardHeight/getKeyboardHeight.ts similarity index 78% rename from packages/mobile/src/utils/keyboard/getKeyboardHeight.ts rename to packages/mobile/src/utils/getKeyboardHeight/getKeyboardHeight.ts index 1712aae2..6c605660 100644 --- a/packages/mobile/src/utils/keyboard/getKeyboardHeight.ts +++ b/packages/mobile/src/utils/getKeyboardHeight/getKeyboardHeight.ts @@ -1,29 +1,27 @@ -import { isServer } from '../isServer.ts'; +import { isServer } from '../isServer/index.ts'; /** - * Returns the current on-screen keyboard height in pixels. + * @description + * `getKeyboardHeight` is a utility function that returns the current on-screen keyboard height in pixels. * * This function uses the Visual Viewport API to calculate the keyboard height. * It assumes a modern environment where Visual Viewport is supported * (Safari / WKWebView 14+, Chrome / Android WebView 80+). * * The keyboard height is computed as: - * window.innerHeight - visualViewport.height - visualViewport.offsetTop + * `window.innerHeight - visualViewport.height - visualViewport.offsetTop` * * The subtraction of `offsetTop` is required to correctly handle iOS behavior * where the visual viewport may shift vertically when the keyboard appears. * - * @returns {number} The keyboard height in pixels. Returns 0 if the keyboard - * is not visible. + * @returns {number} The keyboard height in pixels. Returns 0 if the keyboard is not visible. * * @example - * ```ts * const height = getKeyboardHeight(); * * if (height > 0) { * footer.style.paddingBottom = `${height}px`; * } - * ``` */ export function getKeyboardHeight(): number { if (isServer()) { diff --git a/packages/mobile/src/utils/getKeyboardHeight/index.ts b/packages/mobile/src/utils/getKeyboardHeight/index.ts new file mode 100644 index 00000000..7c696f7d --- /dev/null +++ b/packages/mobile/src/utils/getKeyboardHeight/index.ts @@ -0,0 +1 @@ +export { getKeyboardHeight } from './getKeyboardHeight.ts'; diff --git a/packages/mobile/src/utils/getKeyboardHeight/ko/getKeyboardHeight.md b/packages/mobile/src/utils/getKeyboardHeight/ko/getKeyboardHeight.md new file mode 100644 index 00000000..478721a5 --- /dev/null +++ b/packages/mobile/src/utils/getKeyboardHeight/ko/getKeyboardHeight.md @@ -0,0 +1,29 @@ +# getKeyboardHeight + +`getKeyboardHeight`은 현재 화면에 표시된 키보드의 높이를 픽셀로 반환하는 유틸리티 함수에요. 이 함수는 Visual Viewport API를 사용하여 키보드 높이를 계산해요. Visual Viewport가 지원되는 현대 환경(사파리 / WKWebView 14+, 크롬 / 안드로이드 WebView 80+)을 가정해요. 키보드 높이는 다음과 같이 계산돼요: `window.innerHeight - visualViewport.height - visualViewport.offsetTop` - `offsetTop`의 차감은 키보드가 나타날 때 iOS 동작을 올바르게 처리하기 위해 필요해요. + +## 인터페이스 + +```ts +function getKeyboardHeight(): number; +``` + +### 파라미터 + +### 반환 값 + + + +## 예시 + +```tsx +const height = getKeyboardHeight(); + +if (height > 0) { + footer.style.paddingBottom = `${height}px`; +} +``` diff --git a/packages/mobile/src/utils/getSafeAreaInset/getSafeAreaInset.md b/packages/mobile/src/utils/getSafeAreaInset/getSafeAreaInset.md new file mode 100644 index 00000000..2a2584f1 --- /dev/null +++ b/packages/mobile/src/utils/getSafeAreaInset/getSafeAreaInset.md @@ -0,0 +1,54 @@ +# getSafeAreaInset + +`getSafeAreaInset` is a utility function that returns all safe area insets in pixels as an object. This function reads the CSS `env(safe-area-inset-*)` values by creating a temporary DOM element and reading its computed style. Safe area insets account for device-specific UI elements: - **top**: Notch, Dynamic Island, or status bar - **bottom**: Home indicator on Face ID devices - **left/right**: Rounded corners in landscape mode Typical values (iPhone with Face ID, portrait mode): - top: 47-59px (notch/Dynamic Island) - bottom: 34px (home indicator) - left/right: 0px + +## Interface + +```ts +function getSafeAreaInset(): SafeAreaInset; +``` + +### Parameters + +### Return Value + + + +## Example + +```tsx +const { top, bottom, left, right } = getSafeAreaInset(); + +header.style.paddingTop = `${top}px`; +footer.style.paddingBottom = `${bottom}px`; +``` diff --git a/packages/mobile/src/utils/safeArea/getSafeAreaInset.ssr.test.ts b/packages/mobile/src/utils/getSafeAreaInset/getSafeAreaInset.ssr.test.ts similarity index 100% rename from packages/mobile/src/utils/safeArea/getSafeAreaInset.ssr.test.ts rename to packages/mobile/src/utils/getSafeAreaInset/getSafeAreaInset.ssr.test.ts diff --git a/packages/mobile/src/utils/safeArea/getSafeAreaInset.test.ts b/packages/mobile/src/utils/getSafeAreaInset/getSafeAreaInset.test.ts similarity index 100% rename from packages/mobile/src/utils/safeArea/getSafeAreaInset.test.ts rename to packages/mobile/src/utils/getSafeAreaInset/getSafeAreaInset.test.ts diff --git a/packages/mobile/src/utils/safeArea/getSafeAreaInset.ts b/packages/mobile/src/utils/getSafeAreaInset/getSafeAreaInset.ts similarity index 80% rename from packages/mobile/src/utils/safeArea/getSafeAreaInset.ts rename to packages/mobile/src/utils/getSafeAreaInset/getSafeAreaInset.ts index b64753c3..8e8d9b4a 100644 --- a/packages/mobile/src/utils/safeArea/getSafeAreaInset.ts +++ b/packages/mobile/src/utils/getSafeAreaInset/getSafeAreaInset.ts @@ -1,4 +1,4 @@ -import { isServer } from '../isServer.ts'; +import { isServer } from '../isServer/index.ts'; export type SafeAreaInset = { /** Top safe area inset in pixels (notch, Dynamic Island, or status bar) */ @@ -12,7 +12,8 @@ export type SafeAreaInset = { }; /** - * Returns all safe area insets in pixels as an object. + * @description + * `getSafeAreaInset` is a utility function that returns all safe area insets in pixels as an object. * * This function reads the CSS `env(safe-area-inset-*)` values by creating * a temporary DOM element and reading its computed style. @@ -27,15 +28,17 @@ export type SafeAreaInset = { * - bottom: 34px (home indicator) * - left/right: 0px * - * @returns Object containing safe area insets for all four sides, or all 0 if not available. + * @returns {SafeAreaInset} Object containing safe area insets for all four sides, or all 0 if not available. + * - top `number` - Top safe area inset in pixels; + * - bottom `number` - Bottom safe area inset in pixels; + * - left `number` - Left safe area inset in pixels; + * - right `number` - Right safe area inset in pixels; * * @example - * ```ts * const { top, bottom, left, right } = getSafeAreaInset(); * * header.style.paddingTop = `${top}px`; * footer.style.paddingBottom = `${bottom}px`; - * ``` */ export function getSafeAreaInset(): SafeAreaInset { if (isServer()) { diff --git a/packages/mobile/src/utils/getSafeAreaInset/index.ts b/packages/mobile/src/utils/getSafeAreaInset/index.ts new file mode 100644 index 00000000..262cf9ee --- /dev/null +++ b/packages/mobile/src/utils/getSafeAreaInset/index.ts @@ -0,0 +1,2 @@ +export type { SafeAreaInset } from './getSafeAreaInset.ts'; +export { getSafeAreaInset } from './getSafeAreaInset.ts'; diff --git a/packages/mobile/src/utils/getSafeAreaInset/ko/getSafeAreaInset.md b/packages/mobile/src/utils/getSafeAreaInset/ko/getSafeAreaInset.md new file mode 100644 index 00000000..9f25d1ea --- /dev/null +++ b/packages/mobile/src/utils/getSafeAreaInset/ko/getSafeAreaInset.md @@ -0,0 +1,62 @@ +# getSafeAreaInset + +`getSafeAreaInset`은 모든 안전 영역 간격을 픽셀 단위로 반환하는 유틸리티 함수에요. 이 함수는 임시 DOM 요소를 만들어서 CSS `env(safe-area-inset-*)` 값을 읽어와 계산된 스타일을 확인합니다. 안전 영역 간격은 기기별 UI 요소를 고려해요: + +- **위**: 노치, 다이나믹 아일랜드 또는 상태 표시줄 +- **아래**: Face ID 장치의 홈 인디케이터 +- **왼쪽/오른쪽**: 가로 모드에서 라운드된 코너 + 전형적인 값 (Face ID가 있는 iPhone, 세로 모드): +- 위: 47-59px (노치/다이나믹 아일랜드) +- 아래: 34px (홈 인디케이터) +- 왼쪽/오른쪽: 0px + +## 인터페이스 + +```ts +function getSafeAreaInset(): SafeAreaInset; +``` + +### 파라미터 + +### 반환 값 + + + +## 예시 + +```tsx +const { top, bottom, left, right } = getSafeAreaInset(); + +header.style.paddingTop = `${top}px`; +footer.style.paddingBottom = `${bottom}px`; +``` diff --git a/packages/mobile/src/utils/index.ts b/packages/mobile/src/utils/index.ts index 17ac963a..d75e4e3b 100644 --- a/packages/mobile/src/utils/index.ts +++ b/packages/mobile/src/utils/index.ts @@ -1,2 +1,10 @@ -export { disableBodyScrollLock, enableBodyScrollLock } from './bodyScrollLock.ts'; -export { isServer } from './isServer.ts'; +export { disableBodyScrollLock } from './disableBodyScrollLock/index.ts'; +export { enableBodyScrollLock } from './enableBodyScrollLock/index.ts'; +export { getKeyboardHeight } from './getKeyboardHeight/index.ts'; +export type { SafeAreaInset } from './getSafeAreaInset/index.ts'; +export { getSafeAreaInset } from './getSafeAreaInset/index.ts'; +export { isAndroid } from './isAndroid/index.ts'; +export { isIOS } from './isIOS/index.ts'; +export { isKeyboardVisible } from './isKeyboardVisible/index.ts'; +export { isServer } from './isServer/index.ts'; +export { subscribeKeyboardHeight } from './subscribeKeyboardHeight/index.ts'; diff --git a/packages/mobile/src/utils/isAndroid/index.ts b/packages/mobile/src/utils/isAndroid/index.ts new file mode 100644 index 00000000..cd6352fa --- /dev/null +++ b/packages/mobile/src/utils/isAndroid/index.ts @@ -0,0 +1 @@ +export { isAndroid } from './isAndroid.ts'; diff --git a/packages/mobile/src/utils/isAndroid/isAndroid.md b/packages/mobile/src/utils/isAndroid/isAndroid.md new file mode 100644 index 00000000..988de7de --- /dev/null +++ b/packages/mobile/src/utils/isAndroid/isAndroid.md @@ -0,0 +1,39 @@ +# isAndroid + +`isAndroid` is a utility function that detects whether the current device is running Android. Notes: - All Android browsers include the token "Android" in the user agent. + +## Interface + +```ts +function isAndroid(userAgent: string): boolean; +``` + +### Parameters + + + +### Return Value + + + +## Example + +```tsx +if (isAndroid()) { + // Android-specific code + enableAndroidOptimizations(); +} +``` diff --git a/packages/mobile/src/utils/isAndroid/isAndroid.ssr.test.ts b/packages/mobile/src/utils/isAndroid/isAndroid.ssr.test.ts new file mode 100644 index 00000000..1a60f766 --- /dev/null +++ b/packages/mobile/src/utils/isAndroid/isAndroid.ssr.test.ts @@ -0,0 +1,15 @@ +/** + * @vitest-environment node + * + * SSR environment tests - runs in Node.js where window is truly undefined + */ +import { describe, expect, it } from 'vitest'; + +import { isAndroid } from './isAndroid.ts'; + +describe('isAndroid SSR environment', () => { + it('should return false in SSR environment', () => { + expect(typeof window).toBe('undefined'); + expect(isAndroid()).toBe(false); + }); +}); diff --git a/packages/mobile/src/utils/isAndroid/isAndroid.test.ts b/packages/mobile/src/utils/isAndroid/isAndroid.test.ts new file mode 100644 index 00000000..b356f8fb --- /dev/null +++ b/packages/mobile/src/utils/isAndroid/isAndroid.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { isAndroid } from './isAndroid.ts'; + +describe('isAndroid', () => { + it('should detect Android (Pixel)', () => { + expect(isAndroid('Mozilla/5.0 (Linux; Android 12; Pixel 6) Chrome/120')).toBe(true); + }); + + it('should detect Android (Samsung)', () => { + expect(isAndroid('Mozilla/5.0 (Linux; Android 13; SM-S908B) Chrome/120')).toBe(true); + }); + + it('should return false for iOS', () => { + expect(isAndroid('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)')).toBe(false); + }); + + it('should return false for desktop browser', () => { + expect(isAndroid('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120')).toBe(false); + }); + + it('should return false for Mac browser', () => { + expect(isAndroid('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36')).toBe(false); + }); +}); diff --git a/packages/mobile/src/utils/isAndroid/isAndroid.ts b/packages/mobile/src/utils/isAndroid/isAndroid.ts new file mode 100644 index 00000000..3ac5e64c --- /dev/null +++ b/packages/mobile/src/utils/isAndroid/isAndroid.ts @@ -0,0 +1,32 @@ +import { isServer } from '../isServer/index.ts'; + +/** + * @description + * `isAndroid` is a utility function that detects whether the current device is running Android. + * + * Notes: + * - All Android browsers include the token "Android" in the user agent. + * + * @param {string} [userAgent] - Optional user agent string to check. Defaults to `navigator.userAgent`. + * + * @returns {boolean} `true` if the device is running Android, `false` otherwise. + * Returns `false` on server-side rendering environments. + * + * @example + * if (isAndroid()) { + * // Android-specific code + * enableAndroidOptimizations(); + * } + * + * @example + * // With custom user agent + * const isAndroidDevice = isAndroid('Mozilla/5.0 (Linux; Android 12; Pixel 6) Chrome/120'); + */ +export function isAndroid(userAgent?: string): boolean { + if (isServer()) { + return false; + } + + const ua = userAgent ?? navigator.userAgent; + return /Android/i.test(ua); +} diff --git a/packages/mobile/src/utils/isAndroid/ko/isAndroid.md b/packages/mobile/src/utils/isAndroid/ko/isAndroid.md new file mode 100644 index 00000000..f2017bff --- /dev/null +++ b/packages/mobile/src/utils/isAndroid/ko/isAndroid.md @@ -0,0 +1,39 @@ +# isAndroid + +`isAndroid`는 현재 기기가 안드로이드에서 실행 중인지 감지하는 유틸리티 함수에요. 참고: - 모든 안드로이드 브라우저는 사용자 에이전트에 'Android' 토큰을 포함해요. + +## 인터페이스 + +```ts +function isAndroid(userAgent: string): boolean; +``` + +### 파라미터 + + + +### 반환 값 + + + +## 예시 + +```tsx +if (isAndroid()) { + // 안드로이드 특정 코드 + enableAndroidOptimizations(); +} +``` diff --git a/packages/mobile/src/utils/isIOS/index.ts b/packages/mobile/src/utils/isIOS/index.ts new file mode 100644 index 00000000..a9e0c040 --- /dev/null +++ b/packages/mobile/src/utils/isIOS/index.ts @@ -0,0 +1 @@ +export { isIOS } from './isIOS.ts'; diff --git a/packages/mobile/src/utils/isIOS/isIOS.md b/packages/mobile/src/utils/isIOS/isIOS.md new file mode 100644 index 00000000..c5116d76 --- /dev/null +++ b/packages/mobile/src/utils/isIOS/isIOS.md @@ -0,0 +1,39 @@ +# isIOS + +`isIOS` is a utility function that detects whether the current device is running iOS or iPadOS. Notes on platform inconsistencies: - Prior to iPadOS 13, iPads reported their platform as "iPad" (or matched /iPad/ in UA). - Starting from iPadOS 13, Apple changed the platform string to "MacIntel" to make websites treat iPadOS as desktop-class Safari. However, these devices still expose multi-touch capabilities. + +## Interface + +```ts +function isIOS(userAgent: string): boolean; +``` + +### Parameters + + + +### Return Value + + + +## Example + +```tsx +if (isIOS()) { + // iOS-specific code + enableIOSOptimizations(); +} +``` diff --git a/packages/mobile/src/utils/isIOS/isIOS.ssr.test.ts b/packages/mobile/src/utils/isIOS/isIOS.ssr.test.ts new file mode 100644 index 00000000..483a2bac --- /dev/null +++ b/packages/mobile/src/utils/isIOS/isIOS.ssr.test.ts @@ -0,0 +1,15 @@ +/** + * @vitest-environment node + * + * SSR environment tests - runs in Node.js where window is truly undefined + */ +import { describe, expect, it } from 'vitest'; + +import { isIOS } from './isIOS.ts'; + +describe('isIOS SSR environment', () => { + it('should return false in SSR environment', () => { + expect(typeof window).toBe('undefined'); + expect(isIOS()).toBe(false); + }); +}); diff --git a/packages/mobile/src/utils/isIOS/isIOS.test.ts b/packages/mobile/src/utils/isIOS/isIOS.test.ts new file mode 100644 index 00000000..9ea65276 --- /dev/null +++ b/packages/mobile/src/utils/isIOS/isIOS.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { isIOS } from './isIOS.ts'; + +describe('isIOS', () => { + const originalPlatform = navigator.platform; + const originalMaxTouchPoints = navigator.maxTouchPoints; + + beforeEach(() => { + // Reset to default values before each test + Object.defineProperty(navigator, 'platform', { + value: originalPlatform, + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: originalMaxTouchPoints, + configurable: true, + }); + }); + + afterEach(() => { + // Restore original values after each test + Object.defineProperty(navigator, 'platform', { + value: originalPlatform, + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: originalMaxTouchPoints, + configurable: true, + }); + }); + + it('should detect iOS (iPhone)', () => { + expect(isIOS('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)')).toBe(true); + }); + + it('should detect iOS (iPad)', () => { + expect(isIOS('Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X)')).toBe(true); + }); + + it('should detect iOS (iPod)', () => { + expect(isIOS('Mozilla/5.0 (iPod touch; CPU iPhone OS 15_0 like Mac OS X)')).toBe(true); + }); + + it('should detect iPadOS (MacIntel + touch)', () => { + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 5, + configurable: true, + }); + + expect(isIOS('Mozilla/5.0 (Macintosh; Intel Mac OS X)')).toBe(true); + }); + + it('should return false for Android', () => { + Object.defineProperty(navigator, 'platform', { + value: 'Linux armv8l', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 0, + configurable: true, + }); + + expect(isIOS('Mozilla/5.0 (Linux; Android 12; Pixel 6) Chrome/120')).toBe(false); + }); + + it('should return false for desktop browser', () => { + Object.defineProperty(navigator, 'platform', { + value: 'Win32', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 0, + configurable: true, + }); + + expect(isIOS('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120')).toBe(false); + }); +}); diff --git a/packages/mobile/src/utils/device/device.ts b/packages/mobile/src/utils/isIOS/isIOS.ts similarity index 55% rename from packages/mobile/src/utils/device/device.ts rename to packages/mobile/src/utils/isIOS/isIOS.ts index f3b427f7..7866ec50 100644 --- a/packages/mobile/src/utils/device/device.ts +++ b/packages/mobile/src/utils/isIOS/isIOS.ts @@ -1,7 +1,8 @@ -import { isServer } from '../isServer.ts'; +import { isServer } from '../isServer/index.ts'; /** - * Detects whether the current device is running iOS or iPadOS. + * @description + * `isIOS` is a utility function that detects whether the current device is running iOS or iPadOS. * * Notes on platform inconsistencies: * - Prior to iPadOS 13, iPads reported their platform as "iPad" (or matched /iPad/ in UA). @@ -9,7 +10,20 @@ import { isServer } from '../isServer.ts'; * to make websites treat iPadOS as desktop-class Safari. * However, these devices still expose multi-touch capabilities. * - * @returns `false` on server-side rendering environments. + * @param {string} [userAgent] - Optional user agent string to check. Defaults to `navigator.userAgent`. + * + * @returns {boolean} `true` if the device is running iOS or iPadOS, `false` otherwise. + * Returns `false` on server-side rendering environments. + * + * @example + * if (isIOS()) { + * // iOS-specific code + * enableIOSOptimizations(); + * } + * + * @example + * // With custom user agent + * const isIOSDevice = isIOS('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)'); */ export function isIOS(userAgent?: string): boolean { if (isServer()) { @@ -25,20 +39,3 @@ export function isIOS(userAgent?: string): boolean { return matchesClassicIOS || matchesModernIPad; } - -/** - * Detects whether the current device is running Android. - * - * Notes: - * - All Android browsers include the token "Android" in the user agent. - * - * @returns `false` on server-side rendering environments. - */ -export function isAndroid(userAgent?: string): boolean { - if (isServer()) { - return false; - } - - const ua = userAgent ?? navigator.userAgent; - return /Android/i.test(ua); -} diff --git a/packages/mobile/src/utils/isIOS/ko/isIOS.md b/packages/mobile/src/utils/isIOS/ko/isIOS.md new file mode 100644 index 00000000..ebcdf838 --- /dev/null +++ b/packages/mobile/src/utils/isIOS/ko/isIOS.md @@ -0,0 +1,42 @@ +# isIOS + +`isIOS`는 현재 기기가 iOS 또는 iPadOS를 실행 중인지 감지하는 유틸리티 함수에요. 플랫폼 불일치에 대한 참고 사항: + +- iPadOS 13 이전에는 iPads가 플랫폼을 'iPad'로 보고했었어요 (또는 UA에서 /iPad/와 일치함). +- iPadOS 13부터 Apple은 웹 사이트가 iPadOS를 데스크톱급 Safari로 취급하게하기 위해 플랫폼 문자열을 'MacIntel'로 변경했어요. 그러나 이러한 기기는 여전히 멀티터치 기능을 노출해요. + +## 인터페이스 + +```ts +function isIOS(userAgent: string): boolean; +``` + +### 파라미터 + + + +### 반환 값 + + + +## 예시 + +```tsx +if (isIOS()) { + // iOS에만 해당되는 코드 + enableIOSOptimizations(); +} +``` diff --git a/packages/mobile/src/utils/isKeyboardVisible/index.ts b/packages/mobile/src/utils/isKeyboardVisible/index.ts new file mode 100644 index 00000000..dedb16a6 --- /dev/null +++ b/packages/mobile/src/utils/isKeyboardVisible/index.ts @@ -0,0 +1 @@ +export { isKeyboardVisible } from './isKeyboardVisible.ts'; diff --git a/packages/mobile/src/utils/isKeyboardVisible/isKeyboardVisible.md b/packages/mobile/src/utils/isKeyboardVisible/isKeyboardVisible.md new file mode 100644 index 00000000..ec12fe1c --- /dev/null +++ b/packages/mobile/src/utils/isKeyboardVisible/isKeyboardVisible.md @@ -0,0 +1,29 @@ +# isKeyboardVisible + +`isKeyboardVisible` is a utility function that checks whether the on-screen keyboard is currently visible. This function uses `getKeyboardHeight()` internally and returns `true` if the keyboard height is greater than 0. + +## Interface + +```ts +function isKeyboardVisible(): boolean; +``` + +### Parameters + +### Return Value + + + +## Example + +```tsx +if (isKeyboardVisible()) { + console.log('Keyboard is open'); +} else { + console.log('Keyboard is closed'); +} +``` diff --git a/packages/mobile/src/utils/isKeyboardVisible/isKeyboardVisible.ssr.test.ts b/packages/mobile/src/utils/isKeyboardVisible/isKeyboardVisible.ssr.test.ts new file mode 100644 index 00000000..e8315a89 --- /dev/null +++ b/packages/mobile/src/utils/isKeyboardVisible/isKeyboardVisible.ssr.test.ts @@ -0,0 +1,15 @@ +/** + * @vitest-environment node + * + * SSR environment tests - runs in Node.js where window is truly undefined + */ +import { describe, expect, it } from 'vitest'; + +import { isKeyboardVisible } from './isKeyboardVisible.ts'; + +describe('isKeyboardVisible SSR environment', () => { + it('should return false in SSR environment', () => { + expect(typeof window).toBe('undefined'); + expect(isKeyboardVisible()).toBe(false); + }); +}); diff --git a/packages/mobile/src/utils/keyboard/isKeyboardVisible.test.ts b/packages/mobile/src/utils/isKeyboardVisible/isKeyboardVisible.test.ts similarity index 91% rename from packages/mobile/src/utils/keyboard/isKeyboardVisible.test.ts rename to packages/mobile/src/utils/isKeyboardVisible/isKeyboardVisible.test.ts index 90f7161d..dd720f77 100644 --- a/packages/mobile/src/utils/keyboard/isKeyboardVisible.test.ts +++ b/packages/mobile/src/utils/isKeyboardVisible/isKeyboardVisible.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getKeyboardHeight } from './getKeyboardHeight.ts'; +import { getKeyboardHeight } from '../getKeyboardHeight/index.ts'; + import { isKeyboardVisible } from './isKeyboardVisible.ts'; -vi.mock('./getKeyboardHeight.ts', () => ({ +vi.mock('../getKeyboardHeight/index.ts', () => ({ getKeyboardHeight: vi.fn(), })); diff --git a/packages/mobile/src/utils/keyboard/isKeyboardVisible.ts b/packages/mobile/src/utils/isKeyboardVisible/isKeyboardVisible.ts similarity index 75% rename from packages/mobile/src/utils/keyboard/isKeyboardVisible.ts rename to packages/mobile/src/utils/isKeyboardVisible/isKeyboardVisible.ts index 4812e8d1..91b579c1 100644 --- a/packages/mobile/src/utils/keyboard/isKeyboardVisible.ts +++ b/packages/mobile/src/utils/isKeyboardVisible/isKeyboardVisible.ts @@ -1,7 +1,8 @@ -import { getKeyboardHeight } from './getKeyboardHeight.ts'; +import { getKeyboardHeight } from '../getKeyboardHeight/index.ts'; /** - * Checks whether the on-screen keyboard is currently visible. + * @description + * `isKeyboardVisible` is a utility function that checks whether the on-screen keyboard is currently visible. * * This function uses `getKeyboardHeight()` internally and returns `true` * if the keyboard height is greater than 0. @@ -9,19 +10,15 @@ import { getKeyboardHeight } from './getKeyboardHeight.ts'; * @returns {boolean} `true` if the keyboard is visible, `false` otherwise. * * @example - * ```ts * if (isKeyboardVisible()) { * console.log('Keyboard is open'); * } else { * console.log('Keyboard is closed'); * } - * ``` * * @example - * ```ts * // Conditionally show/hide elements based on keyboard visibility * const showFloatingButton = !isKeyboardVisible(); - * ``` */ export function isKeyboardVisible(): boolean { const keyboardHeight = getKeyboardHeight(); diff --git a/packages/mobile/src/utils/isKeyboardVisible/ko/isKeyboardVisible.md b/packages/mobile/src/utils/isKeyboardVisible/ko/isKeyboardVisible.md new file mode 100644 index 00000000..a4a95032 --- /dev/null +++ b/packages/mobile/src/utils/isKeyboardVisible/ko/isKeyboardVisible.md @@ -0,0 +1,29 @@ +# isKeyboardVisible + +`isKeyboardVisible`은 현재 화면 키보드가 표시되어 있는지를 확인하는 유틸리티 함수에요. 이 함수는 내부적으로 `getKeyboardHeight()`를 사용하고, 키보드 높이가 0보다 크면 `true`를 반환해요. + +## 인터페이스 + +```ts +function isKeyboardVisible(): boolean; +``` + +### 파라미터 + +### 반환 값 + + + +## 예시 + +```tsx +if (isKeyboardVisible()) { + console.log('키보드가 열렸어요'); +} else { + console.log('키보드가 닫혔어요'); +} +``` diff --git a/packages/mobile/src/utils/isServer.ts b/packages/mobile/src/utils/isServer.ts deleted file mode 100644 index 3883ecc7..00000000 --- a/packages/mobile/src/utils/isServer.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @description Check if the code is running on the server. - * - * @returns {boolean} true if running in a server environment (SSR), false otherwise. - * - * @example - * ```ts - * if (isServer()) { - * // SSR-safe code - * return null; - * } - * - * // Client-only code - * window.addEventListener('resize', handleResize); - * ``` - */ -export function isServer(): boolean { - return typeof window === 'undefined'; -} diff --git a/packages/mobile/src/utils/isServer/index.ts b/packages/mobile/src/utils/isServer/index.ts new file mode 100644 index 00000000..0f9c3f71 --- /dev/null +++ b/packages/mobile/src/utils/isServer/index.ts @@ -0,0 +1 @@ +export { isServer } from './isServer.ts'; diff --git a/packages/mobile/src/utils/isServer/isServer.md b/packages/mobile/src/utils/isServer/isServer.md new file mode 100644 index 00000000..5ecc5967 --- /dev/null +++ b/packages/mobile/src/utils/isServer/isServer.md @@ -0,0 +1,31 @@ +# isServer + +`isServer` is a utility function that checks if the code is running on the server. It returns `true` in SSR (Server-Side Rendering) environments where `window` is undefined, and `false` in client-side environments. + +## Interface + +```ts +function isServer(): boolean; +``` + +### Parameters + +### Return Value + + + +## Example + +```tsx +if (isServer()) { + // SSR-safe code + return null; +} + +// Client-only code +window.addEventListener('resize', handleResize); +``` diff --git a/packages/mobile/src/utils/isServer.ssr.test.ts b/packages/mobile/src/utils/isServer/isServer.ssr.test.ts similarity index 100% rename from packages/mobile/src/utils/isServer.ssr.test.ts rename to packages/mobile/src/utils/isServer/isServer.ssr.test.ts diff --git a/packages/mobile/src/utils/isServer.test.ts b/packages/mobile/src/utils/isServer/isServer.test.ts similarity index 100% rename from packages/mobile/src/utils/isServer.test.ts rename to packages/mobile/src/utils/isServer/isServer.test.ts diff --git a/packages/mobile/src/utils/isServer/isServer.ts b/packages/mobile/src/utils/isServer/isServer.ts new file mode 100644 index 00000000..03ca08e8 --- /dev/null +++ b/packages/mobile/src/utils/isServer/isServer.ts @@ -0,0 +1,20 @@ +/** + * @description + * `isServer` is a utility function that checks if the code is running on the server. + * It returns `true` in SSR (Server-Side Rendering) environments where `window` is undefined, + * and `false` in client-side environments. + * + * @returns {boolean} `true` if running in a server environment (SSR), `false` otherwise. + * + * @example + * if (isServer()) { + * // SSR-safe code + * return null; + * } + * + * // Client-only code + * window.addEventListener('resize', handleResize); + */ +export function isServer(): boolean { + return typeof window === 'undefined'; +} diff --git a/packages/mobile/src/utils/isServer/ko/isServer.md b/packages/mobile/src/utils/isServer/ko/isServer.md new file mode 100644 index 00000000..d3f0805a --- /dev/null +++ b/packages/mobile/src/utils/isServer/ko/isServer.md @@ -0,0 +1,30 @@ +# isServer + +`isServer`은 코드가 서버에서 실행되는지 확인하는 유틸리티 함수에요. `window`이 정의되지 않은 SSR (서버사이드 렌더링) 환경에서는 `true`를 반환하고, 클라이언트 사이드 환경에서는 `false`를 반환해요. + +## 인터페이스 + +```ts +function isServer(): boolean; +``` + +### 파라미터 + +### 반환 값 + + + +## 예시 + +```tsx +if (isServer()) { + // SSR에 안전한 코드 + return null; +} + +// 클라이언트 전용 코드 +window.addEventListener('resize', handleResize); +``` diff --git a/packages/mobile/src/utils/subscribeKeyboardHeight/index.ts b/packages/mobile/src/utils/subscribeKeyboardHeight/index.ts new file mode 100644 index 00000000..3259dc78 --- /dev/null +++ b/packages/mobile/src/utils/subscribeKeyboardHeight/index.ts @@ -0,0 +1 @@ +export { subscribeKeyboardHeight } from './subscribeKeyboardHeight.ts'; diff --git a/packages/mobile/src/utils/subscribeKeyboardHeight/ko/subscribeKeyboardHeight.md b/packages/mobile/src/utils/subscribeKeyboardHeight/ko/subscribeKeyboardHeight.md new file mode 100644 index 00000000..a29deebb --- /dev/null +++ b/packages/mobile/src/utils/subscribeKeyboardHeight/ko/subscribeKeyboardHeight.md @@ -0,0 +1,72 @@ +# subscribeKeyboardHeight + +`subscribeKeyboardHeight`는 화면 키보드 높이의 변경 사항을 구독하는 유틸리티 함수에요. 제공된 콜백은 키보드 높이가 변경될 때마다 호출돼요. 키보드가 나타나거나 사라지거나 크기가 변경될 때 등이 포함돼요. 내부적으로 이 함수는 Visual Viewport에서 `resize` 및 `scroll` 이벤트를 모두 듣습니다: - `resize`: 시각적 뷰포트 높이가 변경될 때 트리거됨 - `scroll`: 시각적 뷰포트 오프셋이 변경될 때 트리거됨(iOS의 경우 뷰포트가 리사이징 없이 이동할 수 있어 중요함) 성능 최적화: - 과도한 콜백 호출을 방지하기 위해 기본적으로 쓰로틀링(16ms, 약 60fps) - 높이가 변경되지 않았을 때 콜백을 건너뛰기 + +## 인터페이스 + +```ts +function subscribeKeyboardHeight( + options: SubscribeKeyboardHeightOptions +): SubscribeKeyboardHeightResult; +``` + +### 파라미터 + + + +### 반환 값 + + + +## 예시 + +```tsx +const { unsubscribe } = subscribeKeyboardHeight({ + callback: height => { + footer.style.paddingBottom = `${height}px`; + }, + immediate: true, +}); + +// 나중에 정리가 필요할 때 +unsubscribe(); +``` diff --git a/packages/mobile/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.md b/packages/mobile/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.md new file mode 100644 index 00000000..a0ae46ef --- /dev/null +++ b/packages/mobile/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.md @@ -0,0 +1,75 @@ +# subscribeKeyboardHeight + +`subscribeKeyboardHeight` is a utility function that subscribes to changes in the on-screen keyboard height. The provided callback is invoked whenever the keyboard height may change, including when the keyboard appears, disappears, or changes size. Internally, this function listens to both `resize` and `scroll` events on the Visual Viewport: - `resize`: triggered when the visual viewport height changes - `scroll`: triggered when the visual viewport offset changes (important for iOS where the viewport can shift without resizing) Performance optimizations: - Throttled by default (16ms, ~60fps) to prevent excessive callback invocations - Skips callback when height hasn't changed (deduplication) + +## Interface + +```ts +function subscribeKeyboardHeight( + options: SubscribeKeyboardHeightOptions +): SubscribeKeyboardHeightResult; +``` + +### Parameters + + + +### Return Value + + + +## Example + +```tsx +const { unsubscribe } = subscribeKeyboardHeight({ + callback: height => { + footer.style.paddingBottom = `${height}px`; + }, + immediate: true, +}); + +// Later, when cleanup is needed +unsubscribe(); +``` diff --git a/packages/mobile/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.ssr.test.ts b/packages/mobile/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.ssr.test.ts new file mode 100644 index 00000000..0ea82c2f --- /dev/null +++ b/packages/mobile/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.ssr.test.ts @@ -0,0 +1,21 @@ +/** + * @vitest-environment node + * + * SSR environment tests - runs in Node.js where window is truly undefined + */ +import { describe, expect, it, vi } from 'vitest'; + +import { subscribeKeyboardHeight } from './subscribeKeyboardHeight.ts'; + +describe('subscribeKeyboardHeight SSR environment', () => { + it('should return noop unsubscribe in SSR environment', () => { + expect(typeof window).toBe('undefined'); + + const callback = vi.fn(); + const { unsubscribe } = subscribeKeyboardHeight({ callback }); + + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mobile/src/utils/keyboard/subscribeKeyboardHeight.test.ts b/packages/mobile/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.test.ts similarity index 100% rename from packages/mobile/src/utils/keyboard/subscribeKeyboardHeight.test.ts rename to packages/mobile/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.test.ts diff --git a/packages/mobile/src/utils/keyboard/subscribeKeyboardHeight.ts b/packages/mobile/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.ts similarity index 77% rename from packages/mobile/src/utils/keyboard/subscribeKeyboardHeight.ts rename to packages/mobile/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.ts index a0dbe4e2..09d03083 100644 --- a/packages/mobile/src/utils/keyboard/subscribeKeyboardHeight.ts +++ b/packages/mobile/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.ts @@ -1,5 +1,5 @@ -import { isServer } from '../isServer.ts'; -import { getKeyboardHeight } from '../keyboard/getKeyboardHeight.ts'; +import { getKeyboardHeight } from '../getKeyboardHeight/index.ts'; +import { isServer } from '../isServer/index.ts'; type SubscribeKeyboardHeightOptions = { /** @@ -27,7 +27,8 @@ type SubscribeKeyboardHeightResult = { }; /** - * Subscribes to changes in the on-screen keyboard height. + * @description + * `subscribeKeyboardHeight` is a utility function that subscribes to changes in the on-screen keyboard height. * * The provided callback is invoked whenever the keyboard height may change, * including when the keyboard appears, disappears, or changes size. @@ -42,15 +43,15 @@ type SubscribeKeyboardHeightResult = { * - Throttled by default (16ms, ~60fps) to prevent excessive callback invocations * - Skips callback when height hasn't changed (deduplication) * - * @param options - Configuration options - * @param options.callback - A function that will be called with the updated keyboard height in pixels. - * @param options.immediate - If true, the callback will be invoked immediately with the current keyboard height. - * @param options.throttleMs - Throttle interval in milliseconds (default: 16ms). + * @param {SubscribeKeyboardHeightOptions} options - Configuration options + * @param {(height: number) => void} options.callback - A function that will be called with the updated keyboard height in pixels. + * @param {boolean} [options.immediate=false] - If true, the callback will be invoked immediately with the current keyboard height. + * @param {number} [options.throttleMs=16] - Throttle interval in milliseconds. * - * @returns An object containing the unsubscribe function. + * @returns {SubscribeKeyboardHeightResult} An object containing the unsubscribe function. + * - unsubscribe `() => void` - Unsubscribes all listeners and stops receiving keyboard height updates. * * @example - * ```ts * const { unsubscribe } = subscribeKeyboardHeight({ * callback: (height) => { * footer.style.paddingBottom = `${height}px`; @@ -60,7 +61,6 @@ type SubscribeKeyboardHeightResult = { * * // Later, when cleanup is needed * unsubscribe(); - * ``` */ export function subscribeKeyboardHeight({ callback,