refactor(Breadcrumb): simplify entity structure and add tests
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
export * from './services';
|
||||
export * from './store/scrollBreadcrumbsStore.svelte';
|
||||
export * from './types/types.ts';
|
||||
|
||||
-29
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { handleTitleStatusChanged } from './handleTitleStatusChanged/handleTitleStatusChanged';
|
||||
@@ -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
|
||||
* <script lang="ts">
|
||||
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
*
|
||||
* onMount(() => {
|
||||
* scrollBreadcrumbsStore.add({
|
||||
* index: 0,
|
||||
* title: 'Introduction',
|
||||
* element: document.getElementById('intro')!
|
||||
* }, 80); // 80px offset for sticky header
|
||||
* });
|
||||
* </script>
|
||||
*
|
||||
* <div id="intro">Introduction</div>
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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<BreadcrumbItem[]>([]);
|
||||
/** Set of indices that have scrolled past (exited viewport while scrolling down) */
|
||||
#scrolledPast = $state<Set<number>>(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();
|
||||
|
||||
@@ -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<Element>();
|
||||
|
||||
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<IntersectionObserverEntry> = {
|
||||
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<typeof vi.spyOn>;
|
||||
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
||||
let scrollToSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user