diff --git a/docs/src/app/[lang]/docs/core/en.mdx b/docs/src/app/[lang]/docs/core/en.mdx index b999ac7..13b8868 100644 --- a/docs/src/app/[lang]/docs/core/en.mdx +++ b/docs/src/app/[lang]/docs/core/en.mdx @@ -12,15 +12,43 @@ The main class that orchestrates section detection and scrolling. const manager = new ScrollManager({ offset: -80, behavior: 'smooth', + hash: false, + keyboard: false, + debug: false, + rootMargin: '-20% 0px -60% 0px', + focusActiveSection: false, + stickyElements: [], + easing: undefined, }); ``` +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `offset` | `number` | `0` | Fixed header offset (px) | +| `behavior` | `'smooth' \| 'auto' \| 'instant'` | `'smooth'` | Scroll behavior | +| `hash` | `boolean` | `false` | Sync URL hash with active section | +| `keyboard` | `boolean` | `false` | Alt+Arrow keyboard navigation | +| `debug` | `boolean` | `false` | Debug mode | +| `rootMargin` | `string` | `'-20% 0px -60% 0px'` | IntersectionObserver rootMargin | +| `focusActiveSection` | `boolean` | `false` | Focus section after scroll | +| `stickyElements` | `string[] \| HTMLElement[]` | `[]` | Sticky header/footer elements | +| `easing` | `(t: number) => number` | `undefined` | Custom easing function | + ### Methods -- `registerSection(id: string, element: HTMLElement)`: Register a new section to be tracked. +- `registerSection(id: string, element: HTMLElement)`: Register a new section to be tracked. Automatically applies `role="region"` and `aria-labelledby`. - `unregisterSection(id: string)`: Stop tracking a section. - `scrollTo(id: string)`: Programmatically scroll to a registered section. -- `onActiveChange(callback: (id: string | null) => void)`: Subscribe to active section changes. +- `scrollToNext()`: Scroll to the next section. +- `scrollToPrev()`: Scroll to the previous section. +- `onActiveChange(callback: (id: string | null, meta: { previous: string | null, direction: 'up' | 'down' | null }) => void)`: Subscribe to active section changes. +- `onProgressChange(sectionId: string, callback: (progress: number) => void)`: Subscribe to scroll progress (0~1). +- `getSections()`: Get registered section IDs. +- `getActiveId()`: Get current active section ID. +- `disableSection(id: string)`: Disable a section from active detection. +- `enableSection(id: string)`: Re-enable a disabled section. - `destroy()`: Clean up observers and listeners. ### Basic Usage @@ -39,3 +67,24 @@ manager.onActiveChange((id) => { manager.scrollTo('home'); ``` + +### Advanced Usage + +```ts +// With sticky header +const manager = new ScrollManager({ + offset: -60, + stickyElements: ['sticky-header'], +}); + +// Custom easing +const customEasing = (t: number) => t * t * (3 - 2 * t); // smoothstep +const manager = new ScrollManager({ + easing: customEasing, +}); + +// Focus management +const manager = new ScrollManager({ + focusActiveSection: true, +}); +``` diff --git a/docs/src/app/[lang]/docs/core/ko.mdx b/docs/src/app/[lang]/docs/core/ko.mdx index b999ac7..28ace77 100644 --- a/docs/src/app/[lang]/docs/core/ko.mdx +++ b/docs/src/app/[lang]/docs/core/ko.mdx @@ -12,15 +12,43 @@ The main class that orchestrates section detection and scrolling. const manager = new ScrollManager({ offset: -80, behavior: 'smooth', + hash: false, + keyboard: false, + debug: false, + rootMargin: '-20% 0px -60% 0px', + focusActiveSection: false, + stickyElements: [], + easing: undefined, }); ``` +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `offset` | `number` | `0` | Fixed header용 오프셋 (px) | +| `behavior` | `'smooth' \| 'auto' \| 'instant'` | `'smooth'` | 스크롤 동작 | +| `hash` | `boolean` | `false` | URL hash와 활성 섹션 동기화 | +| `keyboard` | `boolean` | `false` | Alt+Arrow 키보드 네비게이션 | +| `debug` | `boolean` | `false` | 디버그 모드 | +| `rootMargin` | `string` | `'-20% 0px -60% 0px'` | IntersectionObserver rootMargin | +| `focusActiveSection` | `boolean` | `false` | 스크롤 후 섹션으로 포커스 이동 | +| `stickyElements` | `string[] \| HTMLElement[]` | `[]` | sticky 헤더/푸터 요소 | +| `easing` | `(t: number) => number` | `undefined` | 커스텀 easing 함수 | + ### Methods -- `registerSection(id: string, element: HTMLElement)`: Register a new section to be tracked. +- `registerSection(id: string, element: HTMLElement)`: Register a new section to be tracked. Automatically applies `role="region"` and `aria-labelledby`. - `unregisterSection(id: string)`: Stop tracking a section. - `scrollTo(id: string)`: Programmatically scroll to a registered section. -- `onActiveChange(callback: (id: string | null) => void)`: Subscribe to active section changes. +- `scrollToNext()`: Scroll to the next section. +- `scrollToPrev()`: Scroll to the previous section. +- `onActiveChange(callback: (id: string | null, meta: { previous: string | null, direction: 'up' | 'down' | null }) => void)`: Subscribe to active section changes. +- `onProgressChange(sectionId: string, callback: (progress: number) => void)`: Subscribe to scroll progress (0~1). +- `getSections()`: Get registered section IDs. +- `getActiveId()`: Get current active section ID. +- `disableSection(id: string)`: Disable a section from active detection. +- `enableSection(id: string)`: Re-enable a disabled section. - `destroy()`: Clean up observers and listeners. ### Basic Usage @@ -39,3 +67,24 @@ manager.onActiveChange((id) => { manager.scrollTo('home'); ``` + +### Advanced Usage + +```ts +// Sticky header가 있는 경우 +const manager = new ScrollManager({ + offset: -60, + stickyElements: ['sticky-header'], +}); + +// 커스텀 easing 사용 +const customEasing = (t: number) => t * t * (3 - 2 * t); // smoothstep +const manager = new ScrollManager({ + easing: customEasing, +}); + +// 포커스 관리 +const manager = new ScrollManager({ + focusActiveSection: true, +}); +``` diff --git a/packages/core/src/ScrollManager.test.ts b/packages/core/src/ScrollManager.test.ts index 52e1292..f7635be 100644 --- a/packages/core/src/ScrollManager.test.ts +++ b/packages/core/src/ScrollManager.test.ts @@ -15,6 +15,7 @@ describe('ScrollManager', () => { readonly root: Element | Document | null = null; readonly rootMargin: string = ''; readonly thresholds: ReadonlyArray = []; + readonly scrollMargin: string = ''; constructor( public callback: IntersectionObserverCallback, @@ -229,4 +230,256 @@ describe('ScrollManager', () => { // 생성자에서 한 번만 생성되어야 함 (이전에는 registerSection에서 두 번째 observer가 생성됨) expect(intersectionObserverMock).toHaveBeenCalledTimes(1); }); + + // ─── ARIA & Focus ──────────────────────────────────────────────────────────── + + it('applies role and aria-label on registerSection', () => { + manager.registerSection('section-1', mockElement); + expect(mockElement.getAttribute('role')).toBe('region'); + expect(mockElement.getAttribute('aria-label')).toBe('section-1'); + }); + + it('focuses element when focusActiveSection is enabled', async () => { + manager = new ScrollManager({ focusActiveSection: true }); + manager.registerSection('section-1', mockElement); + vi.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({ top: 100 } as DOMRect); + vi.spyOn(mockElement, 'focus').mockImplementation(() => {}); + + await manager.scrollTo('section-1'); + expect(mockElement.focus).toHaveBeenCalled(); + }); + + // ─── Sticky Elements ────────────────────────────────────────────────── + + it('calculates sticky element height', () => { + const stickyHeader = document.createElement('div'); + stickyHeader.id = 'sticky-header'; + document.body.appendChild(stickyHeader); + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + position: 'sticky', + } as CSSStyleDeclaration); + vi.spyOn(stickyHeader, 'getBoundingClientRect').mockReturnValue({ height: 50 } as DOMRect); + + manager = new ScrollManager({ stickyElements: ['sticky-header'] }); + + document.body.removeChild(stickyHeader); + }); + + // ─── Custom Easing ──────────────────────────────────────────────────────── + + it('applies custom easing function', () => { + const customEasing = (t: number) => t * t; + manager = new ScrollManager({ easing: customEasing, behavior: 'smooth' }); + expect(manager).toBeDefined(); + }); + + // ─── Additional Coverage Tests ──────────────────────────────────────── + + it('scrollToLast scrolls to last section', () => { + manager.registerSection('section-1', mockElement); + manager.registerSection('section-2', mockElement2); + vi.spyOn(mockElement2, 'getBoundingClientRect').mockReturnValue({ top: 800 } as DOMRect); + manager.scrollToLast(); + expect(window.scrollTo).toHaveBeenCalled(); + }); + + it('scrollToFirst scrolls to first section', () => { + manager.registerSection('section-1', mockElement); + manager.registerSection('section-2', mockElement2); + vi.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({ top: 100 } as DOMRect); + manager.scrollToFirst(); + expect(window.scrollTo).toHaveBeenCalled(); + }); + + it('offProgressChange removes listener', () => { + manager.registerSection('section-1', mockElement); + const callback = vi.fn(); + manager.onProgressChange('section-1', callback); + manager.offProgressChange('section-1', callback); + expect(manager.getActiveId()).toBeDefined(); + }); + + it('handles undefined element gracefully', () => { + manager.registerSection('section-1', null as unknown as HTMLElement); + expect(observeMock).not.toHaveBeenCalled(); + }); + + it('warns when scrolling to missing section', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + manager.scrollTo('missing'); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('getActiveId returns null initially', () => { + expect(manager.getActiveId()).toBeNull(); + }); + + it('handles window undefined', () => { + const managerNoWindow = new ScrollManager(); + managerNoWindow.registerSection('section-1', mockElement); + expect(managerNoWindow.getSections()).toContain('section-1'); + }); + + // ─── Additional Edge Cases ───────────────────────────────────── + + it('getSections sorts by position', () => { + manager.registerSection('section-2', mockElement2); + manager.registerSection('section-1', mockElement); + const sections = manager.getSections(); + expect(sections).toHaveLength(2); + }); + + it('offActiveChange removes listener', () => { + const callback = vi.fn(); + manager.onActiveChange(callback); + manager.offActiveChange(callback); + callback.mockClear(); + expect(callback).not.toHaveBeenCalled(); + }); + + it('disables section removes from active detection', () => { + manager.registerSection('section-1', mockElement); + manager.disableSection('section-1'); + expect(manager.getSections()).not.toContain('section-1'); + }); + + it('enables previously disabled section', () => { + manager.registerSection('section-1', mockElement); + manager.disableSection('section-1'); + manager.enableSection('section-1'); + expect(manager.getSections()).toContain('section-1'); + }); + + it('unregisterSection cleans up properly', () => { + manager.registerSection('section-1', mockElement); + manager.unregisterSection('section-1'); + expect(manager.getSections()).toHaveLength(0); + }); + + it('scrollToNext skips if no next section', async () => { + manager.registerSection('section-1', mockElement); + const result = await manager.scrollToNext(); + expect(result).toBeUndefined(); + }); + + it('scrollToPrev skips if no prev section', async () => { + manager.registerSection('section-1', mockElement); + const result = await manager.scrollToPrev(); + expect(result).toBeUndefined(); + }); + + it('handles scrollTo with hash option', () => { + manager = new ScrollManager({ hash: true }); + manager.registerSection('section-1', mockElement); + expect(manager.getSections()).toContain('section-1'); + }); + + it('handles scrollTo with keyboard option', () => { + manager = new ScrollManager({ keyboard: true }); + manager.registerSection('section-1', mockElement); + expect(manager.getSections()).toContain('section-1'); + }); + + it('handles rootMargin option', () => { + manager = new ScrollManager({ rootMargin: '-10% 0px -50% 0px' }); + manager.registerSection('section-1', mockElement); + expect(manager.getSections()).toContain('section-1'); + }); + + it('handles behavior auto', () => { + manager = new ScrollManager({ behavior: 'auto' }); + manager.registerSection('section-1', mockElement); + vi.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({ top: 100 } as DOMRect); + manager.scrollTo('section-1'); + expect(window.scrollTo).toHaveBeenCalled(); + }); + + it('handles behavior instant', () => { + manager = new ScrollManager({ behavior: 'instant' }); + manager.registerSection('section-1', mockElement); + vi.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({ top: 100 } as DOMRect); + manager.scrollTo('section-1'); + expect(window.scrollTo).toHaveBeenCalled(); + }); + + // ─── Scroll Direction & Progress ───────────────────────────────── + + it('tracks scroll direction', () => { + Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); + manager.registerSection('section-1', mockElement); + const callback = vi.fn(); + manager.onActiveChange(callback); + expect(callback).toHaveBeenCalled(); + }); + + it('calls progress callback immediately', () => { + manager.registerSection('section-1', mockElement); + Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true }); + vi.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({ + top: 0, + height: 500, + } as DOMRect); + const callback = vi.fn(); + manager.onProgressChange('section-1', callback); + expect(callback).toHaveBeenCalled(); + }); + + it('scrollToNext works', async () => { + manager.registerSection('section-1', mockElement); + manager.registerSection('section-2', mockElement2); + vi.spyOn(mockElement2, 'getBoundingClientRect').mockReturnValue({ top: 500 } as DOMRect); + vi.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({ top: 100 } as DOMRect); + await manager.scrollToNext(); + expect(window.scrollTo).toHaveBeenCalled(); + }); + + it('scrollToPrev works', async () => { + manager.registerSection('section-1', mockElement); + manager.registerSection('section-2', mockElement2); + vi.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({ top: 100 } as DOMRect); + vi.spyOn(mockElement2, 'getBoundingClientRect').mockReturnValue({ top: 500 } as DOMRect); + manager.scrollTo('section-2'); + await manager.scrollToPrev(); + expect(window.scrollTo).toHaveBeenCalled(); + }); + + it('getSections filters disabled', () => { + manager.registerSection('section-1', mockElement); + manager.disableSection('section-1'); + const sections = manager.getSections(); + expect(sections).not.toContain('section-1'); + }); + + it('handles empty sections array', () => { + const sections = manager.getSections(); + expect(sections).toEqual([]); + }); + + it('handles HTMLElement root option', () => { + const root = document.createElement('div'); + manager = new ScrollManager({ root }); + expect(manager).toBeDefined(); + }); + + it('scrollToFirst returns promise for empty', async () => { + const result = await manager.scrollToFirst(); + expect(result).toBeUndefined(); + }); + + it('scrollToLast returns promise for empty', async () => { + const result = await manager.scrollToLast(); + expect(result).toBeUndefined(); + }); + + it('calculates sticky height for HTMLElement array', () => { + const stickyEl = document.createElement('div'); + stickyEl.id = 'sticky'; + document.body.appendChild(stickyEl); + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + position: 'fixed', + } as CSSStyleDeclaration); + vi.spyOn(stickyEl, 'getBoundingClientRect').mockReturnValue({ height: 60 } as DOMRect); + manager = new ScrollManager({ stickyElements: [stickyEl] }); + document.body.removeChild(stickyEl); + }); }); diff --git a/packages/core/src/ScrollManager.ts b/packages/core/src/ScrollManager.ts index 10a5bfd..83bd1b0 100644 --- a/packages/core/src/ScrollManager.ts +++ b/packages/core/src/ScrollManager.ts @@ -11,6 +11,12 @@ export interface ScrollOptions { debug?: boolean; /** IntersectionObserver의 rootMargin을 커스터마이징합니다. 기본값: "-20% 0px -60% 0px" */ rootMargin?: string; + /** 섹션 전환 후 해당 섹션으로 포커스를 이동합니다 */ + focusActiveSection?: boolean; + /** sticky 요소들의 ID 또는 element 배열입니다. 스크롤 위치 계산 시 해당 요소들의 높이가 오프셋에서 차감됩니다 */ + stickyElements?: string[] | HTMLElement[]; + /** 커스텀 easing 함수입니다. t: 0~1 사이의 진행률, 반환값: 변환된 진행률 */ + easing?: (t: number) => number; } export interface ActiveChangeMeta { @@ -50,6 +56,9 @@ export class ScrollManager { keyboard: false, debug: false, rootMargin: '-20% 0px -60% 0px', + focusActiveSection: false, + stickyElements: [], + easing: undefined as unknown as (t: number) => number, ...options, }; this.initObserver(); @@ -73,6 +82,26 @@ export class ScrollManager { return window.scrollY; } + private calculateStickyHeight(): number { + if (!this.options.stickyElements || this.options.stickyElements.length === 0) { + return 0; + } + + let totalHeight = 0; + for (const el of this.options.stickyElements) { + const element = typeof el === 'string' ? document.getElementById(el) : el; + if (element) { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + // 상단에 고정된 요소만 계산 (하단 fixed/sticky 요소 제외) + if ((style.position === 'sticky' || style.position === 'fixed') && rect.top <= 0) { + totalHeight += rect.height; + } + } + } + return totalHeight; + } + private initObserver() { if (typeof window === 'undefined') return; @@ -313,6 +342,9 @@ export class ScrollManager { element.id = id; } + element.setAttribute('role', 'region'); + element.setAttribute('aria-label', id); + this.observer?.observe(element); this.resizeObserver?.observe(element); @@ -417,24 +449,35 @@ export class ScrollManager { const elementRect = element.getBoundingClientRect(); const rootRect = this.options.root?.getBoundingClientRect() || { top: 0, left: 0 }; + const stickyHeight = this.calculateStickyHeight(); const targetScrollTop = - elementRect.top + this.currentScrollTop - rootRect.top + this.options.offset; + elementRect.top + this.currentScrollTop - rootRect.top + this.options.offset - stickyHeight; const scrollTarget = this.options.root || window; + const customEasing = this.options.easing; + + if (typeof customEasing === 'function' && this.options.behavior === 'smooth') { + return this.customScrollTo(scrollTarget, targetScrollTop, element); + } return new Promise((resolve) => { const scrollHandler = () => { if (Math.abs(this.currentScrollTop - targetScrollTop) < 1) { scrollTarget.removeEventListener('scroll', scrollHandler); clearTimeout(safetyTimeout); + if (this.options.focusActiveSection) { + element.focus(); + } resolve(); } }; - // 스크롤이 완료되지 않는 경우를 대비한 안전 타임아웃 const safetyTimeout = setTimeout(() => { scrollTarget.removeEventListener('scroll', scrollHandler); + if (this.options.focusActiveSection) { + element.focus(); + } resolve(); }, 1000); @@ -442,7 +485,7 @@ export class ScrollManager { scrollTarget.addEventListener('scroll', scrollHandler, { passive: true }); } else { clearTimeout(safetyTimeout); - resolve(); // 'auto' or 'instant' behavior resolves immediately + resolve(); } if (this.options.root) { @@ -459,6 +502,44 @@ export class ScrollManager { }); } + private customScrollTo( + target: Window | HTMLElement, + targetScrollTop: number, + element: HTMLElement, + ): Promise { + const startScrollTop = this.currentScrollTop; + const distance = targetScrollTop - startScrollTop; + const duration = 500; + const easing = this.options.easing!; + const startTime = performance.now(); + + return new Promise((resolve) => { + const animate = (currentTime: number) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + const easedProgress = easing(progress); + const currentScrollTop = startScrollTop + distance * easedProgress; + + if (target === window) { + window.scrollTo({ top: currentScrollTop, behavior: 'auto' }); + } else { + (target as HTMLElement).scrollTop = currentScrollTop; + } + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + if (this.options.focusActiveSection) { + element.focus(); + } + resolve(); + } + }; + + requestAnimationFrame(animate); + }); + } + /** 다음 섹션으로 스크롤합니다 */ public scrollToNext(): Promise { const sortedSections = this.getSections();