From d08e0c62099c78a6e45e6e80cd875db403b53a17 Mon Sep 17 00:00:00 2001 From: bae080311 Date: Sun, 26 Apr 2026 13:39:16 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=9D=B4=EC=8A=88=20#36,=20#37,=20?= =?UTF-8?q?#20=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 추가: 접근성 (ARIA roles + focusActiveSection option) - 추가: sticky elements 지원 (stickyElements option) - 추가: 커스텀 easing 함수 지원 (easing option) - 테스트: 새 기능에 대한 테스트 cases 추가 --- packages/core/src/ScrollManager.test.ts | 42 ++++++++++++ packages/core/src/ScrollManager.ts | 86 ++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/packages/core/src/ScrollManager.test.ts b/packages/core/src/ScrollManager.test.ts index 52e1292..3a37af8 100644 --- a/packages/core/src/ScrollManager.test.ts +++ b/packages/core/src/ScrollManager.test.ts @@ -229,4 +229,46 @@ describe('ScrollManager', () => { // 생성자에서 한 번만 생성되어야 함 (이전에는 registerSection에서 두 번째 observer가 생성됨) expect(intersectionObserverMock).toHaveBeenCalledTimes(1); }); + + // ─── ARIA & Focus ──────────────────────────────────────────────────────────── + + it('applies role and aria-labelledby on registerSection', () => { + manager.registerSection('section-1', mockElement); + expect(mockElement.getAttribute('role')).toBe('region'); + expect(mockElement.getAttribute('aria-labelledby')).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(); + }); }); diff --git a/packages/core/src/ScrollManager.ts b/packages/core/src/ScrollManager.ts index 10a5bfd..95560d3 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 | undefined; } 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 | undefined, ...options, }; this.initObserver(); @@ -73,6 +82,25 @@ 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); + if (style.position === ' sticky' || style.position === 'fixed') { + totalHeight += rect.height; + } + } + } + return totalHeight; + } + private initObserver() { if (typeof window === 'undefined') return; @@ -313,6 +341,9 @@ export class ScrollManager { element.id = id; } + element.setAttribute('role', 'region'); + element.setAttribute('aria-labelledby', id); + this.observer?.observe(element); this.resizeObserver?.observe(element); @@ -417,24 +448,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 +484,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 +501,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(); From 6a19bfc53d01474be69f2f25177054e2a98b6b5c Mon Sep 17 00:00:00 2001 From: bae080311 Date: Sun, 26 Apr 2026 14:06:37 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20IntersectionObserverMock=EC=97=90=20?= =?UTF-8?q?scrollMargin=E3=83=97=E3=83=AD=E3=83=91=E3=83=86=E3=82=A3?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/ScrollManager.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/ScrollManager.test.ts b/packages/core/src/ScrollManager.test.ts index 3a37af8..6c96983 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, From 9d203c6325c657bbcf43e723ded3e029b3a1c1e9 Mon Sep 17 00:00:00 2001 From: bae080311 Date: Sun, 26 Apr 2026 14:12:03 +0900 Subject: [PATCH 3/6] =?UTF-8?q?docs:=20=EC=BD=94=EC=96=B4=20API=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EC=97=90=20=EC=83=88=20=EC=98=B5=EC=85=98?= =?UTF-8?q?=EB=93=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - focusActiveSection, stickyElements, easing 옵션 설명 추가 - 새로운 메서드들 (scrollToNext, scrollToPrev 등) 추가 - Advanced Usage 예제 추가 - EN/KO 문서 모두 업데이트 --- docs/src/app/[lang]/docs/core/en.mdx | 53 ++++++++++++++++++++++++++-- docs/src/app/[lang]/docs/core/ko.mdx | 53 ++++++++++++++++++++++++++-- 2 files changed, 102 insertions(+), 4 deletions(-) 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, +}); +``` From 389e72b7f06d7463bc37c0209da2438b562a407a Mon Sep 17 00:00:00 2001 From: bae080311 Date: Sun, 26 Apr 2026 14:20:53 +0900 Subject: [PATCH 4/6] =?UTF-8?q?test:=20=EC=BB=A4=EB=B2=84=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=EC=A6=9D=EA=B0=80=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scrollToNext/Prev 테스트 추가 - onProgressChange 테스트 추가 -各种 엣지 케이스 테스트 추가 - 51 tests로 증가 --- packages/core/src/ScrollManager.test.ts | 195 ++++++++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/packages/core/src/ScrollManager.test.ts b/packages/core/src/ScrollManager.test.ts index 6c96983..acf06c5 100644 --- a/packages/core/src/ScrollManager.test.ts +++ b/packages/core/src/ScrollManager.test.ts @@ -272,4 +272,199 @@ describe('ScrollManager', () => { 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(); + }); }); From 71bbdc20df24716c618cba32f27b924b5e14f940 Mon Sep 17 00:00:00 2001 From: bae080311 Date: Sun, 26 Apr 2026 16:45:56 +0900 Subject: [PATCH 5/6] =?UTF-8?q?test:=20=EC=B6=94=EA=B0=80=20=EC=97=A3?= =?UTF-8?q?=EC=A7=80=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scrollToFirst/Last 테스트 - stickyElements HTMLElement 배열 테스트 - 52 tests로 증가 - 포맷 수정 --- packages/core/src/ScrollManager.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ScrollManager.test.ts b/packages/core/src/ScrollManager.test.ts index acf06c5..f2f9bc1 100644 --- a/packages/core/src/ScrollManager.test.ts +++ b/packages/core/src/ScrollManager.test.ts @@ -415,7 +415,10 @@ describe('ScrollManager', () => { 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); + vi.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({ + top: 0, + height: 500, + } as DOMRect); const callback = vi.fn(); manager.onProgressChange('section-1', callback); expect(callback).toHaveBeenCalled(); @@ -467,4 +470,16 @@ describe('ScrollManager', () => { 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); + }); }); From c7a0ff392925e394231eabd2a135202921c7610e Mon Sep 17 00:00:00 2001 From: bae080311 Date: Sun, 26 Apr 2026 17:17:47 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20gemini=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ' sticky' → 'sticky': 앞 공백으로 인해 항상 false였던 문자열 비교 버그 수정 - stickyHeight 덧셈 → 뺄셈: sticky 헤더 높이는 타겟 스크롤 위치에서 빼야 함 - 상단 고정 요소만 계산: rect.top <= 0 조건으로 하단 sticky 요소 제외 - aria-labelledby → aria-label: 자기 참조는 접근성 표준에 부적합 - easing 반환 타입: number | undefined → number (NaN 방지) - easing non-null assertion 제거: 타입 수정으로 불필요해진 ! 제거 - JSDoc 한국어로 수정 (일본어 혼입 제거) Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/ScrollManager.test.ts | 4 ++-- packages/core/src/ScrollManager.ts | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/core/src/ScrollManager.test.ts b/packages/core/src/ScrollManager.test.ts index f2f9bc1..f7635be 100644 --- a/packages/core/src/ScrollManager.test.ts +++ b/packages/core/src/ScrollManager.test.ts @@ -233,10 +233,10 @@ describe('ScrollManager', () => { // ─── ARIA & Focus ──────────────────────────────────────────────────────────── - it('applies role and aria-labelledby on registerSection', () => { + it('applies role and aria-label on registerSection', () => { manager.registerSection('section-1', mockElement); expect(mockElement.getAttribute('role')).toBe('region'); - expect(mockElement.getAttribute('aria-labelledby')).toBe('section-1'); + expect(mockElement.getAttribute('aria-label')).toBe('section-1'); }); it('focuses element when focusActiveSection is enabled', async () => { diff --git a/packages/core/src/ScrollManager.ts b/packages/core/src/ScrollManager.ts index 95560d3..83bd1b0 100644 --- a/packages/core/src/ScrollManager.ts +++ b/packages/core/src/ScrollManager.ts @@ -13,10 +13,10 @@ export interface ScrollOptions { rootMargin?: string; /** 섹션 전환 후 해당 섹션으로 포커스를 이동합니다 */ focusActiveSection?: boolean; - /** sticky 요소들의 ID 또는 element 배열입니다. 스크롤 위치 계산 시これらの 높이가 오프셋에 추가됩니다 */ + /** sticky 요소들의 ID 또는 element 배열입니다. 스크롤 위치 계산 시 해당 요소들의 높이가 오프셋에서 차감됩니다 */ stickyElements?: string[] | HTMLElement[]; /** 커스텀 easing 함수입니다. t: 0~1 사이의 진행률, 반환값: 변환된 진행률 */ - easing?: (t: number) => number | undefined; + easing?: (t: number) => number; } export interface ActiveChangeMeta { @@ -58,7 +58,7 @@ export class ScrollManager { rootMargin: '-20% 0px -60% 0px', focusActiveSection: false, stickyElements: [], - easing: undefined as unknown as (t: number) => number | undefined, + easing: undefined as unknown as (t: number) => number, ...options, }; this.initObserver(); @@ -93,7 +93,8 @@ export class ScrollManager { if (element) { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); - if (style.position === ' sticky' || style.position === 'fixed') { + // 상단에 고정된 요소만 계산 (하단 fixed/sticky 요소 제외) + if ((style.position === 'sticky' || style.position === 'fixed') && rect.top <= 0) { totalHeight += rect.height; } } @@ -342,7 +343,7 @@ export class ScrollManager { } element.setAttribute('role', 'region'); - element.setAttribute('aria-labelledby', id); + element.setAttribute('aria-label', id); this.observer?.observe(element); this.resizeObserver?.observe(element); @@ -451,7 +452,7 @@ export class ScrollManager { const stickyHeight = this.calculateStickyHeight(); const targetScrollTop = - elementRect.top + this.currentScrollTop - rootRect.top + this.options.offset + stickyHeight; + elementRect.top + this.currentScrollTop - rootRect.top + this.options.offset - stickyHeight; const scrollTarget = this.options.root || window; const customEasing = this.options.easing; @@ -516,7 +517,7 @@ export class ScrollManager { const animate = (currentTime: number) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); - const easedProgress = easing(progress)!; + const easedProgress = easing(progress); const currentScrollTop = startScrollTop + distance * easedProgress; if (target === window) {