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';