diff --git a/src/entities/Breadcrumb/index.ts b/src/entities/Breadcrumb/index.ts index 4fdbd0b..4faa8d4 100644 --- a/src/entities/Breadcrumb/index.ts +++ b/src/entities/Breadcrumb/index.ts @@ -1,5 +1,35 @@ +/** + * Breadcrumb entity + * + * Tracks page sections using Intersection Observer with scroll direction + * detection. Sections appear in breadcrumbs when scrolling down and exiting + * the viewport top. + * + * @example + * ```svelte + * + * ``` + */ + export { - handleTitleStatusChanged, + type NavigationAction, scrollBreadcrumbsStore, } from './model'; -export { BreadcrumbHeader } from './ui'; +export { + BreadcrumbHeader, + NavigationWrapper, +} from './ui'; diff --git a/src/entities/Breadcrumb/model/index.ts b/src/entities/Breadcrumb/model/index.ts index 634e557..ae6af4f 100644 --- a/src/entities/Breadcrumb/model/index.ts +++ b/src/entities/Breadcrumb/model/index.ts @@ -1,2 +1,2 @@ -export * from './services'; export * from './store/scrollBreadcrumbsStore.svelte'; +export * from './types/types.ts'; diff --git a/src/entities/Breadcrumb/model/services/handleTitleStatusChanged/handleTitleStatusChanged.ts b/src/entities/Breadcrumb/model/services/handleTitleStatusChanged/handleTitleStatusChanged.ts deleted file mode 100644 index 7e53688..0000000 --- a/src/entities/Breadcrumb/model/services/handleTitleStatusChanged/handleTitleStatusChanged.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { TitleStatusChangeHandler } from '$shared/ui'; -import type { Snippet } from 'svelte'; -import { scrollBreadcrumbsStore } from '../../store/scrollBreadcrumbsStore.svelte'; - -/** - * Updates the breadcrumb store when the title visibility status changes. - * - * @param index - Index of the section - * @param isPast - Whether the section is past the current scroll position - * @param title - Snippet for a title itself - * @param id - ID of the section - * @returns Cleanup callback - */ -export const handleTitleStatusChanged: TitleStatusChangeHandler = ( - index: number, - isPast: boolean, - title?: Snippet<[{ className?: string }]>, - id?: string, -) => { - if (isPast && title) { - scrollBreadcrumbsStore.add({ index, title, id }); - } else { - scrollBreadcrumbsStore.remove(index); - } - - return () => { - scrollBreadcrumbsStore.remove(index); - }; -}; diff --git a/src/entities/Breadcrumb/model/services/index.ts b/src/entities/Breadcrumb/model/services/index.ts deleted file mode 100644 index 81f8f56..0000000 --- a/src/entities/Breadcrumb/model/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { handleTitleStatusChanged } from './handleTitleStatusChanged/handleTitleStatusChanged'; diff --git a/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts b/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts index 35f474f..d67e6d0 100644 --- a/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts +++ b/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts @@ -1,39 +1,240 @@ -import type { Snippet } from 'svelte'; +/** + * Scroll-based breadcrumb tracking store + * + * Tracks page sections using Intersection Observer with scroll direction + * detection. Sections appear in breadcrumbs when scrolling DOWN and exiting + * the viewport top. This creates a natural "breadcrumb trail" as users + * scroll through content. + * + * Features: + * - Scroll direction detection (up/down) + * - Intersection Observer for efficient tracking + * - Smooth scrolling to tracked sections + * - Configurable scroll offset for sticky headers + * + * @example + * ```svelte + * + * + *
Introduction
+ * ``` + */ +/** + * A breadcrumb item representing a tracked section + */ export interface BreadcrumbItem { - /** - * Index of the item to display - */ + /** Unique index for ordering */ index: number; - /** - * ID of the item to navigate to - */ - id?: string; - /** - * Title snippet to render - */ - title: Snippet<[{ className?: string }]>; + /** Display title for the breadcrumb */ + title: string; + /** DOM element to track */ + element: HTMLElement; } +/** + * Scroll-based breadcrumb tracking store + * + * Uses Intersection Observer to detect when sections scroll out of view + * and tracks scroll direction to only show sections the user has scrolled + * past while moving down the page. + */ class ScrollBreadcrumbsStore { + /** All tracked breadcrumb items */ #items = $state([]); + /** Set of indices that have scrolled past (exited viewport while scrolling down) */ + #scrolledPast = $state>(new Set()); + /** Intersection Observer instance */ + #observer: IntersectionObserver | null = null; + /** Offset for smooth scrolling (sticky header height) */ + #scrollOffset = 0; + /** Current scroll direction */ + #isScrollingDown = $state(false); + /** Previous scroll Y position to determine direction */ + #prevScrollY = 0; + /** Throttled scroll handler */ + #handleScroll: (() => void) | null = null; + /** Listener count for cleanup */ + #listenerCount = 0; - get items() { - // Keep them sorted by index for Swiss orderliness - return this.#items.sort((a, b) => a.index - b.index); + /** + * Updates scroll direction based on current position + */ + #updateScrollDirection(): void { + const currentScrollY = window.scrollY; + this.#isScrollingDown = currentScrollY > this.#prevScrollY; + this.#prevScrollY = currentScrollY; } - add(item: BreadcrumbItem) { - if (!this.#items.find(i => i.index === item.index)) { - this.#items.push(item); + + /** + * Initializes the Intersection Observer + * + * Tracks when elements enter/exit viewport with zero threshold + * (fires as soon as any part of element crosses viewport edge). + */ + #initObserver(): void { + if (this.#observer) return; + + this.#observer = new IntersectionObserver( + entries => { + for (const entry of entries) { + const item = this.#items.find(i => i.element === entry.target); + if (!item) continue; + + if (!entry.isIntersecting && this.#isScrollingDown) { + // Element exited viewport while scrolling DOWN - add to breadcrumbs + this.#scrolledPast = new Set(this.#scrolledPast).add(item.index); + } else if (entry.isIntersecting && !this.#isScrollingDown) { + // Element entered viewport while scrolling UP - remove from breadcrumbs + const newSet = new Set(this.#scrolledPast); + newSet.delete(item.index); + this.#scrolledPast = newSet; + } + } + }, + { + threshold: 0, + }, + ); + } + + /** + * Attaches scroll listener for direction detection + */ + #attachScrollListener(): void { + if (this.#listenerCount === 0) { + this.#handleScroll = () => this.#updateScrollDirection(); + window.addEventListener('scroll', this.#handleScroll, { passive: true }); + } + this.#listenerCount++; + } + + /** + * Detaches scroll listener when no items remain + */ + #detachScrollListener(): void { + this.#listenerCount = Math.max(0, this.#listenerCount - 1); + if (this.#listenerCount === 0 && this.#handleScroll) { + window.removeEventListener('scroll', this.#handleScroll); + this.#handleScroll = null; } } - remove(index: number) { + + /** + * Disconnects observer and removes scroll listener + */ + #disconnect(): void { + if (this.#observer) { + this.#observer.disconnect(); + this.#observer = null; + } + this.#detachScrollListener(); + } + + /** All tracked items sorted by index */ + get items(): BreadcrumbItem[] { + return this.#items.slice().sort((a, b) => a.index - b.index); + } + + /** Items that have scrolled past viewport top (visible in breadcrumbs) */ + get scrolledPastItems(): BreadcrumbItem[] { + return this.items.filter(item => this.#scrolledPast.has(item.index)); + } + + /** Index of the most recently scrolled item (active section) */ + get activeIndex(): number | null { + const past = this.scrolledPastItems; + return past.length > 0 ? past[past.length - 1].index : null; + } + + /** + * Check if a specific index has been scrolled past + * @param index - Item index to check + */ + isScrolledPast(index: number): boolean { + return this.#scrolledPast.has(index); + } + + /** + * Add a breadcrumb item to track + * @param item - Breadcrumb item with index, title, and element + * @param offset - Scroll offset in pixels (for sticky headers) + */ + add(item: BreadcrumbItem, offset = 0): void { + if (this.#items.find(i => i.index === item.index)) return; + + this.#scrollOffset = offset; + this.#items.push(item); + this.#attachScrollListener(); + this.#initObserver(); + // Initialize scroll direction + this.#prevScrollY = window.scrollY; + this.#observer?.observe(item.element); + } + + /** + * Remove a breadcrumb item from tracking + * @param index - Index of item to remove + */ + remove(index: number): void { + const item = this.#items.find(i => i.index === index); + if (!item) return; + + this.#observer?.unobserve(item.element); this.#items = this.#items.filter(i => i.index !== index); + + const newSet = new Set(this.#scrolledPast); + newSet.delete(index); + this.#scrolledPast = newSet; + + if (this.#items.length === 0) { + this.#disconnect(); + } + } + + /** + * Smooth scroll to a tracked breadcrumb item + * @param index - Index of item to scroll to + * @param container - Scroll container (window by default) + */ + scrollTo(index: number, container: HTMLElement | Window = window): void { + const item = this.#items.find(i => i.index === index); + if (!item) return; + + const rect = item.element.getBoundingClientRect(); + const scrollTop = container === window ? window.scrollY : (container as HTMLElement).scrollTop; + const target = rect.top + scrollTop - this.#scrollOffset; + + if (container === window) { + window.scrollTo({ top: target, behavior: 'smooth' }); + } else { + (container as HTMLElement).scrollTo({ + top: target - (container as HTMLElement).getBoundingClientRect().top + + (container as HTMLElement).scrollTop - window.scrollY, + behavior: 'smooth', + }); + } } } -export function createScrollBreadcrumbsStore() { +/** + * Creates a new scroll breadcrumbs store instance + */ +export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore { return new ScrollBreadcrumbsStore(); } +/** + * Singleton scroll breadcrumbs store instance + */ export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore(); diff --git a/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.test.ts b/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.test.ts new file mode 100644 index 0000000..7f6744b --- /dev/null +++ b/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.test.ts @@ -0,0 +1,559 @@ +/** @vitest-environment jsdom */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { + type BreadcrumbItem, + createScrollBreadcrumbsStore, +} from './scrollBreadcrumbsStore.svelte'; + +// Mock IntersectionObserver - class variable to track instances +let mockObserverInstances: MockIntersectionObserver[] = []; + +class MockIntersectionObserver implements IntersectionObserver { + root = null; + rootMargin = ''; + thresholds: number[] = []; + readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = []; + readonly observedElements = new Set(); + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.callbacks.push(callback); + if (options?.rootMargin) this.rootMargin = options.rootMargin; + if (options?.threshold) { + this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold]; + } + mockObserverInstances.push(this); + } + + observe(target: Element): void { + this.observedElements.add(target); + } + + unobserve(target: Element): void { + this.observedElements.delete(target); + } + + disconnect(): void { + this.observedElements.clear(); + } + + takeRecords(): IntersectionObserverEntry[] { + return []; + } + + // Helper method for tests to trigger intersection changes + triggerIntersection(target: Element, isIntersecting: boolean): void { + const entry: Partial = { + target, + isIntersecting, + intersectionRatio: isIntersecting ? 1 : 0, + boundingClientRect: {} as DOMRectReadOnly, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + time: Date.now(), + }; + this.callbacks.forEach(cb => cb([entry as IntersectionObserverEntry], this)); + } +} + +describe('ScrollBreadcrumbsStore', () => { + let scrollListeners: Array<() => void> = []; + let addEventListenerSpy: ReturnType; + let removeEventListenerSpy: ReturnType; + let scrollToSpy: ReturnType; + + // Helper to create mock elements + const createMockElement = (): HTMLElement => { + const el = document.createElement('div'); + Object.defineProperty(el, 'getBoundingClientRect', { + value: vi.fn(() => ({ + top: 100, + left: 0, + bottom: 200, + right: 100, + width: 100, + height: 100, + x: 0, + y: 100, + toJSON: () => ({}), + })), + }); + return el; + }; + + // Helper to create breadcrumb item + const createItem = (index: number, title: string, element?: HTMLElement): BreadcrumbItem => ({ + index, + title, + element: element ?? createMockElement(), + }); + + beforeEach(() => { + mockObserverInstances = []; + scrollListeners = []; + + // Set up IntersectionObserver mock before creating store + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + + // Mock window.scrollTo + scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + + // Track scroll event listeners + addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation( + (event: string, listener: EventListenerOrEventListenerObject, options?: any) => { + if (event === 'scroll') { + scrollListeners.push(listener as () => void); + } + return undefined; + }, + ); + + removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation( + (event: string, listener: EventListenerOrEventListenerObject) => { + if (event === 'scroll') { + const index = scrollListeners.indexOf(listener as () => void); + if (index > -1) scrollListeners.splice(index, 1); + } + return undefined; + }, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Adding items', () => { + it('should add an item and track it', () => { + const store = createScrollBreadcrumbsStore(); + const element = createMockElement(); + const item = createItem(0, 'Section 1', element); + + store.add(item); + + expect(store.items).toHaveLength(1); + expect(store.items[0]).toEqual(item); + }); + + it('should ignore duplicate indices', () => { + const store = createScrollBreadcrumbsStore(); + const element1 = createMockElement(); + const element2 = createMockElement(); + + store.add(createItem(0, 'First', element1)); + store.add(createItem(0, 'Second', element2)); + + expect(store.items).toHaveLength(1); + expect(store.items[0].title).toBe('First'); + }); + + it('should add multiple items with different indices', () => { + const store = createScrollBreadcrumbsStore(); + store.add(createItem(0, 'First')); + store.add(createItem(1, 'Second')); + store.add(createItem(2, 'Third')); + + expect(store.items).toHaveLength(3); + expect(store.items.map(i => i.index)).toEqual([0, 1, 2]); + }); + + it('should attach scroll listener when first item is added', () => { + const store = createScrollBreadcrumbsStore(); + expect(scrollListeners).toHaveLength(0); + + store.add(createItem(0, 'First')); + + expect(scrollListeners).toHaveLength(1); + }); + + it('should initialize observer with element', () => { + const store = createScrollBreadcrumbsStore(); + const element = createMockElement(); + store.add(createItem(0, 'Test', element)); + + expect(mockObserverInstances).toHaveLength(1); + expect(mockObserverInstances[0].observedElements.has(element)).toBe(true); + }); + }); + + describe('Removing items', () => { + it('should remove an item by index', () => { + const store = createScrollBreadcrumbsStore(); + store.add(createItem(0, 'First')); + store.add(createItem(1, 'Second')); + store.add(createItem(2, 'Third')); + + store.remove(1); + + expect(store.items).toHaveLength(2); + expect(store.items.map(i => i.index)).toEqual([0, 2]); + }); + + it('should do nothing when removing non-existent index', () => { + const store = createScrollBreadcrumbsStore(); + store.add(createItem(0, 'First')); + store.add(createItem(1, 'Second')); + + store.remove(999); + + expect(store.items).toHaveLength(2); + }); + + it('should unobserve element when removed', () => { + const store = createScrollBreadcrumbsStore(); + const element = createMockElement(); + store.add(createItem(0, 'First', element)); + + expect(mockObserverInstances[0].observedElements.has(element)).toBe(true); + + store.remove(0); + + expect(mockObserverInstances[0].observedElements.has(element)).toBe(false); + }); + + it('should disconnect observer when no items remain', () => { + const store = createScrollBreadcrumbsStore(); + store.add(createItem(0, 'First')); + store.add(createItem(1, 'Second')); + + expect(addEventListenerSpy).toHaveBeenCalled(); + const initialCallCount = addEventListenerSpy.mock.calls.length; + + store.remove(0); + // addEventListener was called for the first item, still 1 call + expect(addEventListenerSpy.mock.calls.length).toBe(initialCallCount); + + store.remove(1); + // The listener count should be 0 now - disconnect was called + // We verify the observer was disconnected + expect(mockObserverInstances[0].observedElements.size).toBe(0); + }); + + it('should reattach listener when adding after cleanup', () => { + const store = createScrollBreadcrumbsStore(); + store.add(createItem(0, 'First')); + store.remove(0); + + expect(scrollListeners).toHaveLength(0); + + store.add(createItem(1, 'Second')); + expect(scrollListeners).toHaveLength(1); + }); + }); + + describe('Intersection Observer behavior', () => { + it('should add to scrolledPast when element exits viewport while scrolling down', () => { + // Set initial scrollY before creating store/adding items + Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true }); + + const store = createScrollBreadcrumbsStore(); + const element = createMockElement(); + store.add(createItem(0, 'First', element)); + + // Simulate scrolling down (scrollY increases) + Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); + scrollListeners.forEach(l => l()); + + // Trigger intersection: element exits viewport while scrolling down + mockObserverInstances[0].triggerIntersection(element, false); + + expect(store.isScrolledPast(0)).toBe(true); + expect(store.scrolledPastItems).toHaveLength(1); + }); + + it('should not add to scrolledPast when not scrolling down', () => { + const store = createScrollBreadcrumbsStore(); + const element = createMockElement(); + store.add(createItem(0, 'First', element)); + + // scrollY stays at 0 (not scrolling down) + Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true }); + scrollListeners.forEach(l => l()); + + // Element exits viewport + mockObserverInstances[0].triggerIntersection(element, false); + + expect(store.isScrolledPast(0)).toBe(false); + }); + + it('should remove from scrolledPast when element enters viewport while scrolling up', () => { + const store = createScrollBreadcrumbsStore(); + const element = createMockElement(); + store.add(createItem(0, 'First', element)); + + // First, scroll down and exit viewport + Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); + scrollListeners.forEach(l => l()); + mockObserverInstances[0].triggerIntersection(element, false); + + expect(store.isScrolledPast(0)).toBe(true); + + // Now scroll up (decrease scrollY) and element enters viewport + Object.defineProperty(window, 'scrollY', { value: 50, writable: true, configurable: true }); + scrollListeners.forEach(l => l()); + mockObserverInstances[0].triggerIntersection(element, true); + + expect(store.isScrolledPast(0)).toBe(false); + }); + }); + + describe('scrollTo method', () => { + it('should scroll to item by index with window as container', () => { + const store = createScrollBreadcrumbsStore(); + const element = createMockElement(); + store.add(createItem(0, 'First', element)); + + store.scrollTo(0, window); + + expect(scrollToSpy).toHaveBeenCalledWith( + expect.objectContaining({ + behavior: 'smooth', + }), + ); + }); + + it('should do nothing when index does not exist', () => { + const store = createScrollBreadcrumbsStore(); + store.add(createItem(0, 'First')); + + store.scrollTo(999); + + expect(scrollToSpy).not.toHaveBeenCalled(); + }); + + it('should use scroll offset when calculating target position', () => { + const store = createScrollBreadcrumbsStore(); + + // Reset the mock to clear previous calls + scrollToSpy.mockClear(); + + // Create fresh mock element with specific getBoundingClientRect + const element = document.createElement('div'); + const getBoundingClientRectMock = vi.fn(() => ({ + top: 200, + left: 0, + bottom: 300, + right: 100, + width: 100, + height: 100, + x: 0, + y: 200, + toJSON: () => ({}), + })); + Object.defineProperty(element, 'getBoundingClientRect', { + value: getBoundingClientRectMock, + writable: true, + configurable: true, + }); + + // Add item with 80px offset + store.add(createItem(0, 'Third', element), 80); + + store.scrollTo(0); + + // The offset should be subtracted from the element position + // 200 - 80 = 120 (but in jsdom, getBoundingClientRect might have different behavior) + // Let's just verify smooth behavior is used + expect(scrollToSpy).toHaveBeenCalledWith( + expect.objectContaining({ + behavior: 'smooth', + }), + ); + + // Verify that the scroll position is less than the element top (offset was applied) + const scrollToCall = scrollToSpy.mock.calls[0][0] as ScrollToOptions; + expect((scrollToCall as any).top).toBeLessThan(200); + }); + + it('should handle HTMLElement container', () => { + const store = createScrollBreadcrumbsStore(); + const element = createMockElement(); + store.add(createItem(0, 'Test', element)); + + const container: HTMLElement = { + scrollTop: 50, + scrollTo: vi.fn(), + getBoundingClientRect: () => ({ + top: 0, + bottom: 500, + left: 0, + right: 400, + width: 400, + height: 500, + x: 0, + y: 0, + toJSON: () => ({}), + }), + } as any; + + store.scrollTo(0, container); + + expect(container.scrollTo).toHaveBeenCalledWith( + expect.objectContaining({ + behavior: 'smooth', + }), + ); + }); + }); + + describe('Getters', () => { + it('should return items sorted by index', () => { + const store = createScrollBreadcrumbsStore(); + store.add(createItem(1, 'Second')); + store.add(createItem(0, 'First')); + store.add(createItem(2, 'Third')); + + expect(store.items.map(i => i.index)).toEqual([0, 1, 2]); + expect(store.items.map(i => i.title)).toEqual(['First', 'Second', 'Third']); + }); + + it('should return empty scrolledPastItems initially', () => { + const store = createScrollBreadcrumbsStore(); + store.add(createItem(0, 'First')); + store.add(createItem(1, 'Second')); + + expect(store.scrolledPastItems).toHaveLength(0); + }); + + it('should return items that have been scrolled past', () => { + const store = createScrollBreadcrumbsStore(); + const element = createMockElement(); + store.add(createItem(0, 'First', element)); + store.add(createItem(1, 'Second')); + + // Simulate scrolling down and element exiting viewport + Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); + scrollListeners.forEach(l => l()); + mockObserverInstances[0].triggerIntersection(element, false); + + expect(store.scrolledPastItems).toHaveLength(1); + expect(store.scrolledPastItems[0].index).toBe(0); + }); + }); + + describe('activeIndex getter', () => { + it('should return null when no items are scrolled past', () => { + const store = createScrollBreadcrumbsStore(); + store.add(createItem(0, 'First')); + store.add(createItem(1, 'Second')); + + expect(store.activeIndex).toBeNull(); + }); + + it('should return the last scrolled item index', () => { + // Set initial scroll position + Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true }); + + const store = createScrollBreadcrumbsStore(); + const element0 = createMockElement(); + const element1 = createMockElement(); + store.add(createItem(0, 'First', element0)); + store.add(createItem(1, 'Second', element1)); + + // Scroll down, first item exits + Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); + scrollListeners.forEach(l => l()); + mockObserverInstances[0].triggerIntersection(element0, false); + + expect(store.activeIndex).toBe(0); + + // Second item exits + mockObserverInstances[0].triggerIntersection(element1, false); + + expect(store.activeIndex).toBe(1); + }); + + it('should update active index when scrolling back up', () => { + const store = createScrollBreadcrumbsStore(); + const element0 = createMockElement(); + const element1 = createMockElement(); + store.add(createItem(0, 'First', element0)); + store.add(createItem(1, 'Second', element1)); + + // Scroll past both items + Object.defineProperty(window, 'scrollY', { value: 200, writable: true, configurable: true }); + scrollListeners.forEach(l => l()); + mockObserverInstances[0].triggerIntersection(element0, false); + mockObserverInstances[0].triggerIntersection(element1, false); + + expect(store.activeIndex).toBe(1); + + // Scroll back up, item 1 enters viewport + Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); + scrollListeners.forEach(l => l()); + mockObserverInstances[0].triggerIntersection(element1, true); + + expect(store.activeIndex).toBe(0); + }); + }); + + describe('isScrolledPast', () => { + it('should return false for items not scrolled past', () => { + const store = createScrollBreadcrumbsStore(); + store.add(createItem(0, 'First')); + store.add(createItem(1, 'Second')); + + expect(store.isScrolledPast(0)).toBe(false); + expect(store.isScrolledPast(1)).toBe(false); + }); + + it('should return true for scrolled items', () => { + // Set initial scroll position + Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true }); + + const store = createScrollBreadcrumbsStore(); + const element = createMockElement(); + store.add(createItem(0, 'First', element)); + store.add(createItem(1, 'Second')); + + // Scroll down, first item exits viewport + Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); + scrollListeners.forEach(l => l()); + mockObserverInstances[0].triggerIntersection(element, false); + + expect(store.isScrolledPast(0)).toBe(true); + expect(store.isScrolledPast(1)).toBe(false); + }); + + it('should return false for non-existent indices', () => { + const store = createScrollBreadcrumbsStore(); + store.add(createItem(0, 'First')); + + expect(store.isScrolledPast(999)).toBe(false); + }); + }); + + describe('Scroll direction tracking', () => { + it('should track scroll direction changes', () => { + const store = createScrollBreadcrumbsStore(); + const element = createMockElement(); + store.add(createItem(0, 'First', element)); + + // Initial scroll position + Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true }); + scrollListeners.forEach(l => l()); + + // Scroll down + Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true }); + scrollListeners.forEach(l => l()); + mockObserverInstances[0].triggerIntersection(element, false); + + // Should be in scrolledPast since we scrolled down + expect(store.isScrolledPast(0)).toBe(true); + + // Scroll back up + Object.defineProperty(window, 'scrollY', { value: 50, writable: true, configurable: true }); + scrollListeners.forEach(l => l()); + mockObserverInstances[0].triggerIntersection(element, true); + + // Should be removed since we scrolled up + expect(store.isScrolledPast(0)).toBe(false); + }); + }); +}); diff --git a/src/entities/Breadcrumb/model/types/types.ts b/src/entities/Breadcrumb/model/types/types.ts new file mode 100644 index 0000000..e353e20 --- /dev/null +++ b/src/entities/Breadcrumb/model/types/types.ts @@ -0,0 +1,7 @@ +/** + * Navigation action type for breadcrumb components + * + * A Svelte action that can be attached to navigation elements + * for scroll tracking or other behaviors. + */ +export type NavigationAction = (node: HTMLElement) => void; diff --git a/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte index 4aa6fd6..6eed8c5 100644 --- a/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte +++ b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte @@ -3,65 +3,72 @@ Fixed header for breadcrumbs navigation for sections in the page --> -{#if scrollBreadcrumbsStore.items.length > 0} +{#if breadcrumbs.length > 0}
-
-

- GLYPHDIFF -

+
+ - - -
{/if} diff --git a/src/entities/Breadcrumb/ui/NavigationWrapper/NavigationWrapper.svelte b/src/entities/Breadcrumb/ui/NavigationWrapper/NavigationWrapper.svelte new file mode 100644 index 0000000..3c8d602 --- /dev/null +++ b/src/entities/Breadcrumb/ui/NavigationWrapper/NavigationWrapper.svelte @@ -0,0 +1,44 @@ + + + +{@render content(registerBreadcrumb)} diff --git a/src/entities/Breadcrumb/ui/index.ts b/src/entities/Breadcrumb/ui/index.ts index 76d13e1..a11318c 100644 --- a/src/entities/Breadcrumb/ui/index.ts +++ b/src/entities/Breadcrumb/ui/index.ts @@ -1,3 +1,2 @@ -import BreadcrumbHeader from './BreadcrumbHeader/BreadcrumbHeader.svelte'; - -export { BreadcrumbHeader }; +export { default as BreadcrumbHeader } from './BreadcrumbHeader/BreadcrumbHeader.svelte'; +export { default as NavigationWrapper } from './NavigationWrapper/NavigationWrapper.svelte';