<nav class="supt-chapters-nav">
    <div class="supt-chapters-nav__inner">
        <ul class="supt-chapters-nav__list">
            <li class="supt-chapters-nav__item">
                <a href="#introduction" class="supt-chapters-nav__link" data-width="105">
                    <span class="supt-chapters-nav__link__number">
                        I

                    </span>
                    <span class="supt-chapters-nav__link__title">
                        Introduction
                    </span>
                </a>
            </li>
            <li class="supt-chapters-nav__item">
                <a href="#why-we-innovate" class="supt-chapters-nav__link" data-width="126">
                    <span class="supt-chapters-nav__link__number">
                        II

                    </span>
                    <span class="supt-chapters-nav__link__title">
                        Why we innovate
                    </span>
                </a>
            </li>
            <li class="supt-chapters-nav__item">
                <a href="#locations" class="supt-chapters-nav__link" data-width="82">
                    <span class="supt-chapters-nav__link__number">
                        III

                    </span>
                    <span class="supt-chapters-nav__link__title">
                        Locations
                    </span>
                </a>
            </li>
            <li class="supt-chapters-nav__item">
                <a href="#our-products" class="supt-chapters-nav__link" data-width="110">
                    <span class="supt-chapters-nav__link__number">
                        IV

                    </span>
                    <span class="supt-chapters-nav__link__title">
                        Our products
                    </span>
                </a>
            </li>
        </ul>

        <div class="supt-chapters-nav__progress">
            <div class="supt-chapters-nav__progress__bar"></div>
        </div>
    </div>
</nav>

No notes defined.

{% set roman_numbers = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'] %}

<nav class="supt-chapters-nav">
	<div class="supt-chapters-nav__inner">
		<ul class="supt-chapters-nav__list">
			{% for index, chapter in chapters %}
				<li class="supt-chapters-nav__item">
					<a href="#{{ chapter.id }}" class="supt-chapters-nav__link" data-width="{{ chapter.width }}">
						<span class="supt-chapters-nav__link__number">
							{{ roman_numbers[index] }}
							{# {{ index < 9 ? '0' ~ (index + 1) : (index + 1) }} #}
						</span>
						<span class="supt-chapters-nav__link__title">
							{{ chapter.title }}
						</span>
					</a>
				</li>
			{% endfor %}
		</ul>

		<div class="supt-chapters-nav__progress">
			<div class="supt-chapters-nav__progress__bar"></div>
		</div>
	</div>
</nav>
{
  "chapters": [
    {
      "title": "Introduction",
      "id": "introduction",
      "width": 105
    },
    {
      "title": "Why we innovate",
      "id": "why-we-innovate",
      "width": 126
    },
    {
      "title": "Locations",
      "id": "locations",
      "width": 82
    },
    {
      "title": "Our products",
      "id": "our-products",
      "width": 110
    }
  ]
}
  • Content:
    .supt-chapters-nav {
    	position: fixed;
    	left: 0;
    	bottom: 0;
    	right: 0;
    	z-index: $z-index-chapters-nav;
    
    	padding: 14px 10px;
    
    	@media (min-width: $breakpoint-md) {
    		padding: 0;
    		bottom: 24px;
    		left: 32px;
    		right: auto;
    	}
    
    	&__inner {
    		overflow-x: auto;
    
    		/* Hide scrollbar */
    		&::-webkit-scrollbar {
    			display: none;
    		}
    
    		@media (min-width: $breakpoint-md) {
    			overflow-x: hidden;
    		}
    	}
    
    	&__list {
    		display: flex;
    		gap: $spacing-4;
    	}
    
    	&__item {
    		position: relative;
    	}
    
    	&__link {
    		font-family: $font-primary;
    		color: $color-white;
    		font-size: 12px;
    		line-height: 18px;
    		letter-spacing: 0.4px;
    		text-transform: uppercase;
    
    		display: flex;
    		gap: $spacing-1;
    
    		opacity: 0.5;
    		transition: opacity $transition-fast;
    
    		&:hover {
    			color: $color-white;
    			opacity: 1;
    		}
    
    		&.-is-active {
    			opacity: 1;
    		}
    	}
    
    	&__link__title {
    		white-space: nowrap;
    
    		@media (min-width: $breakpoint-md) {
    			will-change: opacity, max-width;
    			opacity: 0;
    			max-width: 0;
    			overflow: hidden;
    		}
    	}
    
    	&__progress {
    		margin-top: $spacing-1-5;
    		height: 2px;
    		background-color: rgb(255 255 255 / 50%);
    
    		position: absolute;
    		top: 0;
    		left: 0;
    		right: 0;
    
    		@media (min-width: $breakpoint-md) {
    			position: relative;
    			width: 100%;
    			margin-top: $spacing-1-5;
    		}
    
    		&__bar {
    			position: absolute;
    			top: 0;
    			left: 0;
    			height: 100%;
    			width: 1px;
    			background-color: $color-white;
    
    			transform-origin: left;
    			transform: scaleX(0);
    		}
    	}
    }
    
  • URL: /components/raw/chapters-nav/chapters-nav.css
  • Filesystem Path: src/components/molecules/chapters-nav/chapters-nav.css
  • Size: 1.5 KB
  • Content:
    import { Sizes } from '@/js/Sizes';
    import { animate } from 'animejs';
    
    const ANIMATION_DURATION = 500;
    const STRETCHED_NUMBER_WIDTH = [0, 24, 26, 27];
    
    export class ChaptersNav {
    	$element: HTMLElement;
    	$progressBar: HTMLElement;
    	$links: NodeListOf<HTMLElement>;
    	$titles: NodeListOf<HTMLElement>;
    	$inner: HTMLElement;
    	currentSectionIndex: number = 0;
    	observer: IntersectionObserver | null = null;
    	sizes: Sizes;
    	private scrollHandler: (() => void) | null = null;
    
    	constructor($element: HTMLElement) {
    		this.$element = $element;
    		this.$progressBar = $element.querySelector('.supt-chapters-nav__progress__bar') as HTMLElement;
    		this.$links = $element.querySelectorAll('.supt-chapters-nav__link');
    		this.$titles = $element.querySelectorAll('.supt-chapters-nav__link__title');
    		this.$inner = $element.querySelector('.supt-chapters-nav__inner') as HTMLElement;
    
    		this.sizes = new Sizes();
    
    		this.init();
    	}
    
    	private init(): void {
    		this.setupIntersectionObserver();
    		this.setupScrollHandler();
    
    		// Ensure first link is active by default
    		if (this.$links.length > 0) {
    			this.$links[0].classList.add('-is-active');
    		}
    
    		// Set initial state for titles
    		this.updateTitlesVisibility();
    
    		// Set initial progress bar position
    		this.updateProgressBar({ duration: 0 });
    	}
    
    	private setupIntersectionObserver(): void {
    		// Find all sections on the page that correspond to chapters
    		const sections = this.findChapterSections();
    
    		if (sections.length === 0) return;
    
    		// Configure intersection observer options
    		const options: IntersectionObserverInit = {
    			root: null, // Use viewport as root
    			rootMargin: '-20% 0px -20% 0px', // Trigger when element is in center 60% of viewport
    			threshold: 0, // Trigger as soon as any part enters the center area
    		};
    
    		// Create intersection observer
    		this.observer = new IntersectionObserver(entries => {
    			entries.forEach(entry => {
    				if (entry.isIntersecting) {
    					// Get the section index from data attribute or class
    					const sectionElement = entry.target as HTMLElement;
    					const sectionIndex = this.getSectionIndex(sectionElement);
    
    					if (sectionIndex !== -1 && sectionIndex !== this.currentSectionIndex) {
    						this.currentSectionIndex = sectionIndex;
    						this.updateTitlesVisibility();
    
    						this.updateProgressBar();
    
    						// Auto-scroll to the active link
    						this.scrollToActiveLink();
    					}
    				}
    			});
    		}, options);
    
    		// Start observing all section elements
    		sections.forEach(section => {
    			this.observer?.observe(section);
    		});
    	}
    
    	private findChapterSections(): HTMLElement[] {
    		// Look for sections that might correspond to chapters
    		// This could be sections with specific classes or data attributes
    		const possibleSelectors = ['section[id]'];
    
    		for (const selector of possibleSelectors) {
    			const sections = document.querySelectorAll<HTMLElement>(selector);
    			if (sections.length > 0) {
    				return Array.from(sections);
    			}
    		}
    
    		// Fallback: look for any section elements
    		return Array.from(document.querySelectorAll<HTMLElement>('section'));
    	}
    
    	// Get Section Index from ID
    	private getSectionIndex(sectionElement: HTMLElement): number {
    		// Try to get from id
    		const id = sectionElement.id;
    		if (id) {
    			// Extract the first number from the ID
    			const match = id.match(/(\d+)/);
    			if (match) {
    				// Convert to 0-based index (subtract 1)
    				return parseInt(match[1], 10) - 1;
    			}
    		}
    
    		// Fallback: find position among all sections
    		const allSections = this.findChapterSections();
    		return allSections.indexOf(sectionElement);
    	}
    
    	private setupScrollHandler(): void {
    		// Only add scroll handler on mobile where horizontal scrolling is enabled
    		if (!this.sizes.isDesktop) {
    			this.scrollHandler = () => {
    				this.updateProgressBar({ duration: 0 });
    			};
    			this.$inner.addEventListener('scroll', this.scrollHandler);
    		}
    	}
    
    	private getProgressBarSizes(sectionIndex: number): { markerLeft: number; markerWidth: number } {
    		const $activeLink = this.$links[sectionIndex];
    		if (!$activeLink) return { markerLeft: 0, markerWidth: 0 };
    
    		const linkWidth = parseInt($activeLink.dataset.width || '0');
    
    		// Calculate the position of the marker relative to the container
    		let markerLeft = this.sizes.isDesktop
    			? STRETCHED_NUMBER_WIDTH[sectionIndex] * sectionIndex
    			: $activeLink.getBoundingClientRect().left;
    		let markerWidth = linkWidth;
    
    		if (!this.sizes.isDesktop) {
    			// Handle the left padding of the nav on mobile
    			if (this.currentSectionIndex == 0) {
    				markerLeft -= 10;
    				markerWidth += 10;
    			} else if (this.currentSectionIndex == this.$links.length - 1) {
    				markerWidth += 10;
    			}
    		}
    
    		return {
    			markerLeft,
    			markerWidth,
    		};
    	}
    
    	private updateProgressBar({ duration = 200 }: { duration?: number } = {}): void {
    		const { markerLeft, markerWidth } = this.getProgressBarSizes(this.currentSectionIndex);
    
    		// Animate the marker to the current link position
    		animate(this.$progressBar, {
    			x: markerLeft,
    			scaleX: markerWidth,
    			duration,
    		});
    	}
    
    	private updateTitlesVisibility(): void {
    		if (this.sizes.isDesktop) {
    			// Update current section title only on desktop
    			this.$titles.forEach((title, index) => {
    				if (index === this.currentSectionIndex) {
    					// Show current section title
    					animate(title, {
    						maxWidth: `${this.$links[index].dataset.width}px`,
    						opacity: [title.style.opacity || '0', 1],
    						duration: ANIMATION_DURATION,
    					});
    				} else {
    					// Hide other section titles
    					animate(title, {
    						maxWidth: '0px',
    						opacity: [title.style.opacity || '1', 0],
    						duration: ANIMATION_DURATION,
    					});
    				}
    			});
    		}
    
    		// Update active state of links
    		this.$links.forEach((link, index) => {
    			if (index === this.currentSectionIndex) {
    				link.classList.add('-is-active');
    			} else {
    				link.classList.remove('-is-active');
    			}
    		});
    	}
    
    	private scrollToActiveLink(): void {
    		if (this.sizes.isDesktop) return; // Only scroll on mobile
    
    		const $activeLink = this.$links[this.currentSectionIndex];
    		if (!$activeLink) return;
    
    		$activeLink.scrollIntoView({
    			behavior: 'smooth',
    			block: 'center',
    		});
    	}
    
    	destroy(): void {
    		if (this.observer) {
    			this.observer.disconnect();
    			this.observer = null;
    		}
    
    		// Remove scroll event listener
    		if (this.scrollHandler && !this.sizes.isDesktop) {
    			this.$inner.removeEventListener('scroll', this.scrollHandler);
    			this.scrollHandler = null;
    		}
    	}
    }
    
  • URL: /components/raw/chapters-nav/index.ts
  • Filesystem Path: src/components/molecules/chapters-nav/index.ts
  • Size: 6.4 KB