Some checks failed
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
- Add comprehensive widget plugin infrastructure documentation and workplan - Include complete DocumentNavigator integration documentation - Add TDD test suite with 15 comprehensive test cases for DocumentNavigator - Include widget base classes (Widget, UIWidget) for future development - Add DocumentNavigator plugin definition following planned architecture - Include test runner and demo pages for development validation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
625 lines
19 KiB
JavaScript
625 lines
19 KiB
JavaScript
/**
|
|
* DocumentNavigator Widget
|
|
*
|
|
* Substack-style floating document navigation widget that displays a hierarchical
|
|
* table of contents based on document headings. Supports smooth scrolling,
|
|
* scroll spy, expand/collapse, and responsive behavior.
|
|
*/
|
|
import { UIWidget } from '../base/UIWidget.js';
|
|
|
|
export class DocumentNavigator extends UIWidget {
|
|
constructor(options = {}) {
|
|
super(options);
|
|
|
|
// Navigation state
|
|
this.isCollapsed = this.config.collapsed;
|
|
this.currentSection = null;
|
|
this.headings = [];
|
|
this.navigationTree = [];
|
|
|
|
// Scroll spy state
|
|
this.scrollSpyEnabled = this.config.enableScrollSpy;
|
|
this.scrollThrottle = null;
|
|
|
|
// Event bindings
|
|
this.boundScrollHandler = this.handleScroll.bind(this);
|
|
this.boundResizeHandler = this.handleResize.bind(this);
|
|
|
|
// Initialize responsive behavior
|
|
this.mediaQuery = window.matchMedia('(max-width: 768px)');
|
|
}
|
|
|
|
getDefaultConfig() {
|
|
return {
|
|
...super.getDefaultConfig(),
|
|
position: 'left', // 'left' or 'right'
|
|
collapsed: true, // Start collapsed
|
|
autoHide: true, // Hide on mobile
|
|
maxHeadingLevel: 3, // H1, H2, H3
|
|
enableScrollSpy: true, // Highlight current section
|
|
smoothScroll: true, // Smooth scroll behavior
|
|
animationDuration: 300, // Animation timing
|
|
minHeadings: 2, // Min headings to show navigator
|
|
theme: 'default', // Theme support
|
|
|
|
// Styling options
|
|
width: '280px',
|
|
collapsedWidth: '40px',
|
|
offset: { top: '80px', side: '20px' },
|
|
|
|
// Accessibility
|
|
enableKeyboard: true,
|
|
ariaLabel: 'Document Navigation'
|
|
};
|
|
}
|
|
|
|
async initialize() {
|
|
await super.initialize();
|
|
|
|
// Extract headings from container
|
|
this.extractHeadings();
|
|
this.buildNavigationTree();
|
|
|
|
// Set up event listeners
|
|
if (this.scrollSpyEnabled) {
|
|
window.addEventListener('scroll', this.boundScrollHandler, { passive: true });
|
|
}
|
|
|
|
if (this.config.autoHide) {
|
|
window.addEventListener('resize', this.boundResizeHandler);
|
|
this.handleResize(); // Initial check
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
async render() {
|
|
if (this.isRendered) {
|
|
return this.element;
|
|
}
|
|
|
|
// Check if we have enough headings
|
|
if (this.headings.length < this.config.minHeadings) {
|
|
this.isRendered = true;
|
|
return null; // Don't render if too few headings
|
|
}
|
|
|
|
// Create main container
|
|
this.element = this.createElement('nav', {
|
|
className: 'document-navigator markitect-widget',
|
|
attributes: {
|
|
'aria-label': this.config.ariaLabel,
|
|
'role': 'navigation'
|
|
},
|
|
style: this.getNavigatorStyle()
|
|
});
|
|
|
|
// Apply CSS classes
|
|
this.applyCSSClasses();
|
|
this.addClass('theme-' + this.theme);
|
|
this.addClass('position-' + this.config.position);
|
|
|
|
// Create toggle button (always visible)
|
|
this.createToggleButton();
|
|
|
|
// Create navigation list (hidden when collapsed)
|
|
this.createNavigationList();
|
|
|
|
// Set initial visibility state
|
|
if (this.isCollapsed) {
|
|
await this.collapse({ immediate: true });
|
|
} else {
|
|
await this.expand({ immediate: true });
|
|
}
|
|
|
|
// Append to container
|
|
this.container.appendChild(this.element);
|
|
|
|
// Initialize scroll spy
|
|
if (this.scrollSpyEnabled) {
|
|
this.updateCurrentSection();
|
|
}
|
|
|
|
this.isRendered = true;
|
|
this.emit('rendered');
|
|
|
|
return this.element;
|
|
}
|
|
|
|
createToggleButton() {
|
|
this.toggleButton = this.createElement('button', {
|
|
className: 'navigator-toggle',
|
|
attributes: {
|
|
'type': 'button',
|
|
'aria-label': this.isCollapsed ? 'Expand navigation' : 'Collapse navigation',
|
|
'aria-expanded': !this.isCollapsed
|
|
},
|
|
innerHTML: this.getToggleIcon(),
|
|
style: this.getToggleStyle()
|
|
});
|
|
|
|
// Toggle on click
|
|
this.toggleButton.addEventListener('click', async () => {
|
|
await this.toggle();
|
|
});
|
|
|
|
// Keyboard support
|
|
if (this.config.enableKeyboard) {
|
|
this.toggleButton.addEventListener('keydown', this.handleKeyboard.bind(this));
|
|
}
|
|
|
|
this.element.appendChild(this.toggleButton);
|
|
}
|
|
|
|
createNavigationList() {
|
|
this.navigationList = this.createElement('div', {
|
|
className: 'navigator-list',
|
|
style: this.getListStyle()
|
|
});
|
|
|
|
if (this.headings.length === 0) {
|
|
this.createEmptyState();
|
|
} else {
|
|
this.populateNavigationList();
|
|
}
|
|
|
|
this.element.appendChild(this.navigationList);
|
|
}
|
|
|
|
createEmptyState() {
|
|
const emptyMessage = this.createElement('div', {
|
|
className: 'navigator-empty',
|
|
textContent: 'No headings found',
|
|
style: {
|
|
padding: '1rem',
|
|
textAlign: 'center',
|
|
color: '#666',
|
|
fontStyle: 'italic'
|
|
}
|
|
});
|
|
|
|
this.navigationList.appendChild(emptyMessage);
|
|
}
|
|
|
|
populateNavigationList() {
|
|
// Create header
|
|
const header = this.createElement('div', {
|
|
className: 'navigator-header',
|
|
innerHTML: `
|
|
<h3>Contents</h3>
|
|
<button class="navigator-close" aria-label="Close navigation">✕</button>
|
|
`,
|
|
style: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
padding: '1rem 1rem 0.5rem',
|
|
borderBottom: '1px solid #eee',
|
|
marginBottom: '0.5rem'
|
|
}
|
|
});
|
|
|
|
// Close button functionality
|
|
const closeButton = header.querySelector('.navigator-close');
|
|
closeButton.addEventListener('click', async () => {
|
|
await this.collapse();
|
|
});
|
|
|
|
this.navigationList.appendChild(header);
|
|
|
|
// Create navigation items
|
|
const navContainer = this.createElement('div', {
|
|
className: 'navigator-items',
|
|
style: {
|
|
maxHeight: '70vh',
|
|
overflowY: 'auto',
|
|
padding: '0 0.5rem 1rem'
|
|
}
|
|
});
|
|
|
|
this.renderNavigationTree(navContainer, this.navigationTree);
|
|
this.navigationList.appendChild(navContainer);
|
|
}
|
|
|
|
renderNavigationTree(container, items, level = 0) {
|
|
items.forEach(item => {
|
|
const navItem = this.createElement('div', {
|
|
className: `navigator-item level-${level}`,
|
|
style: {
|
|
marginLeft: `${level * 1}rem`,
|
|
marginBottom: '0.25rem'
|
|
}
|
|
});
|
|
|
|
// Create clickable link
|
|
const link = this.createElement('a', {
|
|
className: 'navigator-link',
|
|
textContent: item.text,
|
|
attributes: {
|
|
'href': `#${item.id}`,
|
|
'data-target': item.id,
|
|
'data-level': item.level,
|
|
'role': 'button',
|
|
'tabindex': '0'
|
|
},
|
|
style: {
|
|
display: 'block',
|
|
padding: '0.5rem 0.75rem',
|
|
textDecoration: 'none',
|
|
color: '#333',
|
|
borderRadius: '4px',
|
|
fontSize: level === 0 ? '0.9rem' : '0.8rem',
|
|
fontWeight: level === 0 ? '600' : '400',
|
|
transition: 'all 0.2s ease',
|
|
cursor: 'pointer'
|
|
}
|
|
});
|
|
|
|
// Hover effects
|
|
link.addEventListener('mouseenter', () => {
|
|
link.style.backgroundColor = '#f0f0f0';
|
|
});
|
|
|
|
link.addEventListener('mouseleave', () => {
|
|
if (!link.classList.contains('active')) {
|
|
link.style.backgroundColor = '';
|
|
}
|
|
});
|
|
|
|
// Click navigation
|
|
link.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
this.navigateToHeading(item.id);
|
|
});
|
|
|
|
navItem.appendChild(link);
|
|
|
|
// Render children recursively
|
|
if (item.children && item.children.length > 0) {
|
|
this.renderNavigationTree(navItem, item.children, level + 1);
|
|
}
|
|
|
|
container.appendChild(navItem);
|
|
});
|
|
}
|
|
|
|
extractHeadings() {
|
|
const headingSelectors = [];
|
|
for (let i = 1; i <= this.config.maxHeadingLevel; i++) {
|
|
headingSelectors.push(`h${i}`);
|
|
}
|
|
|
|
const headingElements = this.container.querySelectorAll(headingSelectors.join(', '));
|
|
|
|
this.headings = Array.from(headingElements).map((heading, index) => {
|
|
// Ensure heading has an ID
|
|
if (!heading.id) {
|
|
heading.id = `heading-${index + 1}`;
|
|
}
|
|
|
|
return {
|
|
element: heading,
|
|
id: heading.id,
|
|
text: heading.textContent.trim(),
|
|
level: parseInt(heading.tagName.substring(1)),
|
|
offset: heading.offsetTop
|
|
};
|
|
});
|
|
|
|
return this.headings;
|
|
}
|
|
|
|
buildNavigationTree() {
|
|
this.navigationTree = [];
|
|
const stack = [];
|
|
|
|
this.headings.forEach(heading => {
|
|
const item = {
|
|
...heading,
|
|
children: []
|
|
};
|
|
|
|
// Find correct parent based on heading level
|
|
while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
|
|
stack.pop();
|
|
}
|
|
|
|
if (stack.length === 0) {
|
|
// Top level item
|
|
this.navigationTree.push(item);
|
|
} else {
|
|
// Child item
|
|
stack[stack.length - 1].children.push(item);
|
|
}
|
|
|
|
stack.push(item);
|
|
});
|
|
|
|
return this.navigationTree;
|
|
}
|
|
|
|
async toggle(options = {}) {
|
|
return this.isCollapsed ? this.expand(options) : this.collapse(options);
|
|
}
|
|
|
|
async expand(options = {}) {
|
|
if (!this.isCollapsed) {
|
|
return this;
|
|
}
|
|
|
|
this.isCollapsed = false;
|
|
|
|
if (this.toggleButton) {
|
|
this.toggleButton.setAttribute('aria-expanded', 'true');
|
|
this.toggleButton.setAttribute('aria-label', 'Collapse navigation');
|
|
this.toggleButton.innerHTML = this.getToggleIcon();
|
|
}
|
|
|
|
if (this.navigationList) {
|
|
if (this.enableAnimations && !options.immediate) {
|
|
await this.animateExpand();
|
|
} else {
|
|
this.navigationList.style.display = '';
|
|
this.element.style.width = this.config.width;
|
|
}
|
|
}
|
|
|
|
this.emit('toggle', { expanded: true });
|
|
return this;
|
|
}
|
|
|
|
async collapse(options = {}) {
|
|
if (this.isCollapsed) {
|
|
return this;
|
|
}
|
|
|
|
this.isCollapsed = true;
|
|
|
|
if (this.toggleButton) {
|
|
this.toggleButton.setAttribute('aria-expanded', 'false');
|
|
this.toggleButton.setAttribute('aria-label', 'Expand navigation');
|
|
this.toggleButton.innerHTML = this.getToggleIcon();
|
|
}
|
|
|
|
if (this.navigationList) {
|
|
if (this.enableAnimations && !options.immediate) {
|
|
await this.animateCollapse();
|
|
} else {
|
|
this.navigationList.style.display = 'none';
|
|
this.element.style.width = this.config.collapsedWidth;
|
|
}
|
|
}
|
|
|
|
this.emit('toggle', { expanded: false });
|
|
return this;
|
|
}
|
|
|
|
async animateExpand() {
|
|
return new Promise(resolve => {
|
|
this.navigationList.style.opacity = '0';
|
|
this.navigationList.style.display = '';
|
|
|
|
// Animate width and opacity
|
|
this.element.style.transition = `width ${this.animationDuration}ms ease-in-out`;
|
|
this.navigationList.style.transition = `opacity ${this.animationDuration}ms ease-in-out`;
|
|
|
|
// Force reflow
|
|
this.element.offsetWidth;
|
|
|
|
this.element.style.width = this.config.width;
|
|
this.navigationList.style.opacity = '1';
|
|
|
|
setTimeout(() => {
|
|
this.element.style.transition = '';
|
|
this.navigationList.style.transition = '';
|
|
resolve();
|
|
}, this.animationDuration);
|
|
});
|
|
}
|
|
|
|
async animateCollapse() {
|
|
return new Promise(resolve => {
|
|
this.element.style.transition = `width ${this.animationDuration}ms ease-in-out`;
|
|
this.navigationList.style.transition = `opacity ${this.animationDuration}ms ease-in-out`;
|
|
|
|
this.navigationList.style.opacity = '0';
|
|
this.element.style.width = this.config.collapsedWidth;
|
|
|
|
setTimeout(() => {
|
|
this.navigationList.style.display = 'none';
|
|
this.element.style.transition = '';
|
|
this.navigationList.style.transition = '';
|
|
resolve();
|
|
}, this.animationDuration);
|
|
});
|
|
}
|
|
|
|
navigateToHeading(headingId) {
|
|
const targetElement = document.getElementById(headingId);
|
|
if (!targetElement) {
|
|
console.warn(`Heading with ID '${headingId}' not found`);
|
|
return;
|
|
}
|
|
|
|
// Update active navigation item
|
|
this.setActiveItem(headingId);
|
|
|
|
// Scroll to target
|
|
if (this.config.smoothScroll) {
|
|
targetElement.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'start',
|
|
inline: 'nearest'
|
|
});
|
|
} else {
|
|
targetElement.scrollIntoView();
|
|
}
|
|
|
|
// Emit navigation event
|
|
this.emit('navigate', { target: headingId, element: targetElement });
|
|
|
|
// Optionally collapse after navigation on mobile
|
|
if (this.mediaQuery.matches && this.config.autoHide) {
|
|
setTimeout(() => this.collapse(), 500);
|
|
}
|
|
}
|
|
|
|
setActiveItem(headingId) {
|
|
// Remove previous active state
|
|
const previousActive = this.findElement('.navigator-link.active');
|
|
if (previousActive) {
|
|
previousActive.classList.remove('active');
|
|
previousActive.style.backgroundColor = '';
|
|
}
|
|
|
|
// Set new active state
|
|
const newActive = this.findElement(`[data-target="${headingId}"]`);
|
|
if (newActive) {
|
|
newActive.classList.add('active');
|
|
newActive.style.backgroundColor = '#e3f2fd';
|
|
newActive.style.color = '#1976d2';
|
|
}
|
|
|
|
this.currentSection = headingId;
|
|
}
|
|
|
|
handleScroll() {
|
|
if (!this.scrollSpyEnabled || !this.isRendered) {
|
|
return;
|
|
}
|
|
|
|
// Throttle scroll events
|
|
if (this.scrollThrottle) {
|
|
return;
|
|
}
|
|
|
|
this.scrollThrottle = setTimeout(() => {
|
|
this.updateCurrentSection();
|
|
this.scrollThrottle = null;
|
|
}, 100);
|
|
}
|
|
|
|
updateCurrentSection() {
|
|
const scrollPosition = window.pageYOffset + 100; // Offset for header
|
|
let currentHeading = null;
|
|
|
|
// Find the current heading based on scroll position
|
|
for (let i = this.headings.length - 1; i >= 0; i--) {
|
|
const heading = this.headings[i];
|
|
if (heading.element.offsetTop <= scrollPosition) {
|
|
currentHeading = heading;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (currentHeading && currentHeading.id !== this.currentSection) {
|
|
this.setActiveItem(currentHeading.id);
|
|
}
|
|
}
|
|
|
|
getCurrentSection() {
|
|
return this.currentSection;
|
|
}
|
|
|
|
handleResize() {
|
|
if (!this.config.autoHide) {
|
|
return;
|
|
}
|
|
|
|
if (this.mediaQuery.matches) {
|
|
// Mobile: hide navigator
|
|
if (this.element) {
|
|
this.element.style.display = 'none';
|
|
}
|
|
} else {
|
|
// Desktop: show navigator
|
|
if (this.element) {
|
|
this.element.style.display = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
handleKeyboard(event) {
|
|
switch (event.key) {
|
|
case 'Enter':
|
|
case ' ':
|
|
event.preventDefault();
|
|
this.toggle();
|
|
break;
|
|
case 'Escape':
|
|
event.preventDefault();
|
|
this.collapse();
|
|
break;
|
|
}
|
|
}
|
|
|
|
getNavigatorStyle() {
|
|
const baseStyle = {
|
|
position: 'fixed',
|
|
top: this.config.offset.top,
|
|
zIndex: '1000',
|
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
|
border: '1px solid #e1e5e9',
|
|
borderRadius: '8px',
|
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
|
backdropFilter: 'blur(8px)',
|
|
width: this.isCollapsed ? this.config.collapsedWidth : this.config.width,
|
|
maxHeight: '80vh',
|
|
overflow: 'hidden',
|
|
transition: 'width 0.3s ease-in-out'
|
|
};
|
|
|
|
// Position-specific styling
|
|
if (this.config.position === 'left') {
|
|
baseStyle.left = this.config.offset.side;
|
|
} else {
|
|
baseStyle.right = this.config.offset.side;
|
|
}
|
|
|
|
return baseStyle;
|
|
}
|
|
|
|
getToggleStyle() {
|
|
return {
|
|
width: '100%',
|
|
height: this.config.collapsedWidth,
|
|
border: 'none',
|
|
backgroundColor: 'transparent',
|
|
cursor: 'pointer',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: '16px',
|
|
color: '#666',
|
|
transition: 'color 0.2s ease'
|
|
};
|
|
}
|
|
|
|
getListStyle() {
|
|
return {
|
|
display: this.isCollapsed ? 'none' : '',
|
|
opacity: this.isCollapsed ? '0' : '1'
|
|
};
|
|
}
|
|
|
|
getToggleIcon() {
|
|
if (this.isCollapsed) {
|
|
return this.config.position === 'left' ? '☰' : '☰';
|
|
} else {
|
|
return '✕';
|
|
}
|
|
}
|
|
|
|
async destroy() {
|
|
// Remove event listeners
|
|
window.removeEventListener('scroll', this.boundScrollHandler);
|
|
window.removeEventListener('resize', this.boundResizeHandler);
|
|
|
|
// Clear throttle
|
|
if (this.scrollThrottle) {
|
|
clearTimeout(this.scrollThrottle);
|
|
}
|
|
|
|
await super.destroy();
|
|
}
|
|
} |