fix: implement Blazor-native login form to properly update authentication state
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m26s
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m26s
Problem: JavaScript login form saved tokens to localStorage but didn't notify CustomAuthenticationStateProvider, causing [Authorize] pages to remain in 'loading' state indefinitely. The provider only reads tokens when: 1. GetAuthenticationStateAsync() is called (page load) 2. NotifyAuthenticationStateChanged() is triggered (UI updates) But JavaScript login didn't trigger either, leaving the authentication state stale. Solution: Convert AdminLoginForm from HTML+JavaScript to pure Blazor component. Now the login flow is: 1. User enters credentials in Blazor form 2. HttpClient POST to /api/auth/login 3. Save tokens to localStorage 4. Call CustomAuthenticationStateProvider.LoginAsync() directly 5. Blazor detects auth state change and re-evaluates [Authorize] pages 6. Dashboard [Authorize] page renders successfully Result: Immediate authentication state update, no loading timeout on protected pages. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
'use strict';
|
||||
|
||||
|
||||
var htmlRoot = document.getElementsByTagName('HTML')[0],
|
||||
//save states
|
||||
savePanelStateEnabled = true,
|
||||
|
||||
//mobile operator on
|
||||
mobileOperator = function () {
|
||||
// Check user agent
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const isMobileUserAgent = /iphone|ipad|ipod|android|blackberry|mini|windows\sce|palm/i.test(userAgent);
|
||||
|
||||
// Check for touch support
|
||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
|
||||
// Check screen size (optional)
|
||||
const isSmallScreen = window.innerWidth <= 992; // Adjust the breakpoint as needed
|
||||
|
||||
// Return true if any of the conditions are met
|
||||
return isMobileUserAgent || isTouchDevice || isSmallScreen;
|
||||
},
|
||||
|
||||
//filter
|
||||
filterClass = function (t, e) {
|
||||
return String(t).split(/[^\w-]+/).filter(function (t) {
|
||||
return e.test(t)
|
||||
}).join(' ')
|
||||
},
|
||||
|
||||
//load
|
||||
loadSettings = function () {
|
||||
var t = sessionStorage.getItem('layoutSettings') || '',
|
||||
e = t ? JSON.parse(t) : {};
|
||||
|
||||
// Load theme setting
|
||||
var savedTheme = e.theme || 'light';
|
||||
htmlRoot.setAttribute('data-bs-theme', savedTheme);
|
||||
|
||||
// Load theme style CSS file only if one was saved
|
||||
var themeStyle = e.themeStyle || '';
|
||||
if (themeStyle) {
|
||||
loadThemeStyle(themeStyle);
|
||||
}
|
||||
|
||||
return Object.assign({
|
||||
htmlRoot: '',
|
||||
theme: savedTheme,
|
||||
themeStyle: themeStyle
|
||||
}, e)
|
||||
},
|
||||
|
||||
//save
|
||||
saveSettings = function () {
|
||||
// Save root HTML classes
|
||||
layoutSettings.htmlRoot = filterClass(htmlRoot.className, /^(set)-/i);
|
||||
|
||||
// Save theme attribute
|
||||
layoutSettings.theme = htmlRoot.getAttribute('data-bs-theme') || 'light';
|
||||
|
||||
// Save theme style CSS path
|
||||
var themeStyleElement = document.getElementById('theme-style');
|
||||
if (themeStyleElement && themeStyleElement.getAttribute('href')) {
|
||||
// Get complete href attribute
|
||||
layoutSettings.themeStyle = themeStyleElement.getAttribute('href');
|
||||
console.log('Saved theme style:', layoutSettings.themeStyle);
|
||||
} else {
|
||||
layoutSettings.themeStyle = '';
|
||||
console.log('No theme style to save');
|
||||
}
|
||||
|
||||
// Log the full settings object before saving
|
||||
console.log('Saving layout settings:', JSON.stringify(layoutSettings));
|
||||
|
||||
// Save to sessionStorage
|
||||
sessionStorage.setItem("layoutSettings", JSON.stringify(layoutSettings));
|
||||
|
||||
// Show saving indicator
|
||||
savingIndicator();
|
||||
},
|
||||
|
||||
// reset
|
||||
resetSettings = function () {
|
||||
sessionStorage.setItem("layoutSettings", "");
|
||||
// reset data-bs-theme
|
||||
htmlRoot.setAttribute('data-bs-theme', 'light');
|
||||
|
||||
// reset theme style element if it exists
|
||||
const themeStyleElement = document.getElementById('theme-style')
|
||||
if (themeStyleElement) {
|
||||
themeStyleElement.setAttribute('href', '');
|
||||
}
|
||||
|
||||
// refresh page
|
||||
window.location.reload();
|
||||
|
||||
|
||||
},
|
||||
|
||||
//load theme style
|
||||
loadThemeStyle = function (themeStyle) {
|
||||
if (!themeStyle) return;
|
||||
|
||||
// Don't do anything if the URL is empty
|
||||
if (!themeStyle.trim()) return;
|
||||
|
||||
// Get existing theme style if it exists
|
||||
var existingThemeStyle = document.getElementById('theme-style');
|
||||
|
||||
if (existingThemeStyle) {
|
||||
// Update existing theme style's href
|
||||
existingThemeStyle.href = themeStyle;
|
||||
} else {
|
||||
// Create new theme style element if none exists
|
||||
var linkElement = document.createElement('link');
|
||||
linkElement.id = 'theme-style'; // Use the standard ID
|
||||
linkElement.rel = 'stylesheet';
|
||||
linkElement.media = 'screen';
|
||||
linkElement.href = themeStyle;
|
||||
document.head.appendChild(linkElement);
|
||||
|
||||
// Flag to indicate this was loaded from sessionStorage
|
||||
linkElement.setAttribute('data-loaded-from-storage', 'true');
|
||||
}
|
||||
},
|
||||
|
||||
//get page id
|
||||
getPageIdentifier = function () {
|
||||
return window.location.pathname.split('/').pop() || 'index.html';
|
||||
},
|
||||
|
||||
//save panel state
|
||||
savePanelState = function () {
|
||||
if (!savePanelStateEnabled) return;
|
||||
|
||||
var state = [];
|
||||
var columns = document.querySelectorAll('.main-content > .row > [class^="col-"]');
|
||||
columns.forEach(function (column, columnIndex) {
|
||||
var panels = column.querySelectorAll('.panel');
|
||||
panels.forEach(function (panel, position) {
|
||||
var panelHeader = panel.querySelector('.panel-hdr');
|
||||
|
||||
// Save panel classes excluding 'panel' and 'panel-fullscreen'
|
||||
var panelClasses = panel.className.split(' ').filter(function (cls) {
|
||||
return cls !== 'panel' && cls !== 'panel-fullscreen';
|
||||
}).join(' ');
|
||||
|
||||
// Save header classes excluding 'panel-hdr'
|
||||
var headerClasses = panelHeader ? panelHeader.className.split(' ').filter(function (cls) {
|
||||
return cls !== 'panel-hdr';
|
||||
}).join(' ') : '';
|
||||
|
||||
state.push({
|
||||
id: panel.id,
|
||||
column: columnIndex,
|
||||
position: position, // Save position within column
|
||||
classes: panelClasses,
|
||||
headerClasses: headerClasses
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var pageId = getPageIdentifier();
|
||||
var allStates = JSON.parse(sessionStorage.getItem('allPanelStates') || '{}');
|
||||
allStates[pageId] = state;
|
||||
sessionStorage.setItem('allPanelStates', JSON.stringify(allStates));
|
||||
savingIndicator();
|
||||
},
|
||||
|
||||
loadPanelState = function () {
|
||||
var pageId = getPageIdentifier();
|
||||
var allStates = JSON.parse(sessionStorage.getItem('allPanelStates') || '{}');
|
||||
var savedState = allStates[pageId];
|
||||
|
||||
if (!savedState) return;
|
||||
|
||||
// Use same selector as save function
|
||||
var columns = Array.from(document.querySelectorAll('.main-content > .row > [class^="col-"]'));
|
||||
|
||||
// Store all existing panels in a map before removing them
|
||||
var panelMap = {};
|
||||
columns.forEach(function (column) {
|
||||
var existingPanels = Array.from(column.querySelectorAll('.panel'));
|
||||
existingPanels.forEach(function (panel) {
|
||||
panelMap[panel.id] = panel;
|
||||
panel.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// Sort state by column and position
|
||||
savedState.sort(function (a, b) {
|
||||
if (a.column === b.column) {
|
||||
return a.position - b.position;
|
||||
}
|
||||
return a.column - b.column;
|
||||
});
|
||||
|
||||
// Reinsert panels in correct order
|
||||
savedState.forEach(function (item) {
|
||||
var panel = panelMap[item.id];
|
||||
if (panel && columns[item.column]) {
|
||||
// Update panel classes
|
||||
panel.className = 'panel ' + (item.classes || '');
|
||||
|
||||
// Update header classes
|
||||
var panelHeader = panel.querySelector('.panel-hdr');
|
||||
if (panelHeader && item.headerClasses) {
|
||||
panelHeader.className = 'panel-hdr ' + item.headerClasses;
|
||||
}
|
||||
|
||||
// Append to correct column
|
||||
columns[item.column].appendChild(panel);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Reset panel state
|
||||
resetPanelState = function () {
|
||||
var pageId = getPageIdentifier();
|
||||
var allStates = JSON.parse(sessionStorage.getItem('allPanelStates') || '{}');
|
||||
delete allStates[pageId];
|
||||
sessionStorage.setItem('allPanelStates', JSON.stringify(allStates));
|
||||
//refresh page
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
savingIndicator = function () {
|
||||
// Create or get the indicator element
|
||||
let indicator = document.getElementById('saving-indicator');
|
||||
if (!indicator) {
|
||||
indicator = document.createElement('div');
|
||||
indicator.id = 'saving-indicator';
|
||||
document.body.appendChild(indicator);
|
||||
}
|
||||
|
||||
// Show saving animation
|
||||
//indicator.textContent = '';
|
||||
indicator.className = 'saving-indicator spinner-border show';
|
||||
|
||||
// After a brief delay, show success and hide
|
||||
setTimeout(() => {
|
||||
//indicator.textContent = '';
|
||||
indicator.className = 'saving-indicator spinner-border show success';
|
||||
setTimeout(() => {
|
||||
indicator.className = 'saving-indicator spinner-border success';
|
||||
}, 500);
|
||||
}, 300);
|
||||
},
|
||||
|
||||
//load page layout settings
|
||||
layoutSettings = loadSettings();
|
||||
layoutSettings.htmlRoot && (htmlRoot.className = layoutSettings.htmlRoot);
|
||||
|
||||
// Load panel settings is triggered just before <script> tag
|
||||
|
||||
|
||||
loadPanelState();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,662 @@
|
||||
// ListFilter - A JavaScript plugin for filtering lists with fuzzy search
|
||||
;(function(window) {
|
||||
'use strict';
|
||||
|
||||
// Cache frequently used selectors
|
||||
var SELECTORS = {
|
||||
NAV_MENU: 'nav-menu',
|
||||
NAV_TITLE: 'nav-title',
|
||||
NAV_LINK_TEXT: '.nav-link-text',
|
||||
FILTER_HIDE: 'js-filter-hide',
|
||||
FILTER_SHOW: 'js-filter-show',
|
||||
LIST_FILTER: 'js-list-filter',
|
||||
LIST_ACTIVE: 'js-list-active'
|
||||
};
|
||||
|
||||
// Constants for configuration
|
||||
var CONSTANTS = {
|
||||
MAX_INPUT_LENGTH: 100,
|
||||
RATE_LIMIT_MS: 100,
|
||||
MAX_MATCHES: 1000,
|
||||
MAX_CACHE_SIZE: 10000,
|
||||
MATCH_WEIGHTS: {
|
||||
EXACT: 3,
|
||||
SUBSTRING: 2,
|
||||
CONSECUTIVE: 0.5,
|
||||
CONSECUTIVE_BONUS: 0.1,
|
||||
NON_CONSECUTIVE: 0.3,
|
||||
PREFIX_BONUS: 0.2,
|
||||
CASE_MATCH_BONUS: 0.1
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce function implementation with immediate option and rate limiting
|
||||
function debounce(func, wait, immediate) {
|
||||
var timeout;
|
||||
var lastRun = 0;
|
||||
|
||||
return function executedFunction() {
|
||||
var context = this;
|
||||
var args = arguments;
|
||||
var now = Date.now();
|
||||
|
||||
// Rate limiting
|
||||
if (now - lastRun < CONSTANTS.RATE_LIMIT_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
var later = function() {
|
||||
timeout = null;
|
||||
if (!immediate) {
|
||||
lastRun = Date.now();
|
||||
func.apply(context, args);
|
||||
}
|
||||
};
|
||||
|
||||
var callNow = immediate && !timeout;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
|
||||
if (callNow) {
|
||||
lastRun = Date.now();
|
||||
func.apply(context, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Improved fuzzy search implementation with caching and enhanced scoring
|
||||
var fuzzyMatchCache = new Map();
|
||||
function fuzzyMatch(pattern, str) {
|
||||
try {
|
||||
if (!pattern || !str) return 0;
|
||||
|
||||
// Check cache first
|
||||
var cacheKey = pattern + '|' + str;
|
||||
if (fuzzyMatchCache.has(cacheKey)) {
|
||||
return fuzzyMatchCache.get(cacheKey);
|
||||
}
|
||||
|
||||
// Clear cache if it gets too large
|
||||
if (fuzzyMatchCache.size > CONSTANTS.MAX_CACHE_SIZE) {
|
||||
fuzzyMatchCache.clear();
|
||||
}
|
||||
|
||||
var lowerPattern = pattern.toLowerCase();
|
||||
var lowerStr = str.toLowerCase();
|
||||
var score = 0;
|
||||
|
||||
// Exact match gets highest score
|
||||
if (str === pattern) {
|
||||
score = CONSTANTS.MATCH_WEIGHTS.EXACT;
|
||||
fuzzyMatchCache.set(cacheKey, score);
|
||||
return score;
|
||||
}
|
||||
|
||||
// Substring match gets second highest score
|
||||
if (lowerStr.includes(lowerPattern)) {
|
||||
score = CONSTANTS.MATCH_WEIGHTS.SUBSTRING;
|
||||
// Bonus for case-sensitive match
|
||||
if (str.includes(pattern)) {
|
||||
score += CONSTANTS.MATCH_WEIGHTS.CASE_MATCH_BONUS;
|
||||
}
|
||||
// Bonus for prefix match
|
||||
if (lowerStr.startsWith(lowerPattern)) {
|
||||
score += CONSTANTS.MATCH_WEIGHTS.PREFIX_BONUS;
|
||||
}
|
||||
fuzzyMatchCache.set(cacheKey, score);
|
||||
return score;
|
||||
}
|
||||
|
||||
var patternIdx = 0;
|
||||
var lastMatchIdx = -1;
|
||||
var consecutiveMatches = 0;
|
||||
var firstMatch = true;
|
||||
|
||||
for (var strIdx = 0; strIdx < str.length && patternIdx < pattern.length; strIdx++) {
|
||||
if (lowerPattern[patternIdx] === lowerStr[strIdx]) {
|
||||
// First match bonus
|
||||
if (firstMatch) {
|
||||
score += CONSTANTS.MATCH_WEIGHTS.PREFIX_BONUS * (1 - strIdx / str.length);
|
||||
firstMatch = false;
|
||||
}
|
||||
|
||||
// Consecutive matches get bonus
|
||||
if (lastMatchIdx === strIdx - 1) {
|
||||
consecutiveMatches++;
|
||||
score += CONSTANTS.MATCH_WEIGHTS.CONSECUTIVE +
|
||||
(consecutiveMatches * CONSTANTS.MATCH_WEIGHTS.CONSECUTIVE_BONUS);
|
||||
} else {
|
||||
consecutiveMatches = 0;
|
||||
score += CONSTANTS.MATCH_WEIGHTS.NON_CONSECUTIVE;
|
||||
}
|
||||
|
||||
// Case matching bonus
|
||||
if (pattern[patternIdx] === str[strIdx]) {
|
||||
score += CONSTANTS.MATCH_WEIGHTS.CASE_MATCH_BONUS;
|
||||
}
|
||||
|
||||
lastMatchIdx = strIdx;
|
||||
patternIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
// Only count as match if all pattern characters were found
|
||||
var finalScore = patternIdx === pattern.length ? score : 0;
|
||||
|
||||
// Normalize score based on string lengths
|
||||
if (finalScore > 0) {
|
||||
finalScore = finalScore * (pattern.length / str.length);
|
||||
}
|
||||
|
||||
fuzzyMatchCache.set(cacheKey, finalScore);
|
||||
return finalScore;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fuzzy match error:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function getNavItemText(element) {
|
||||
try {
|
||||
if (!element || !(element instanceof Element)) {
|
||||
throw new Error('Invalid element provided to getNavItemText');
|
||||
}
|
||||
var navText = element.querySelector(SELECTORS.NAV_LINK_TEXT);
|
||||
return navText ? navText.textContent.trim() : '';
|
||||
} catch (error) {
|
||||
console.error('getNavItemText error:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Main plugin constructor with validation
|
||||
function ListFilter(list, input, options) {
|
||||
try {
|
||||
if (!(this instanceof ListFilter)) {
|
||||
throw new Error('ListFilter must be called with new keyword');
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
if (!list || !input) {
|
||||
throw new Error('List and input parameters are required');
|
||||
}
|
||||
|
||||
this.list = typeof list === 'string' ? document.querySelector(list) : list;
|
||||
this.input = typeof input === 'string' ? document.querySelector(input) : input;
|
||||
|
||||
if (!this.list || !this.input) {
|
||||
throw new Error('Required elements not found. Check your selectors.');
|
||||
}
|
||||
|
||||
if (!(this.list instanceof Element) || !(this.input instanceof Element)) {
|
||||
throw new Error('Invalid DOM elements provided');
|
||||
}
|
||||
|
||||
var defaults = {
|
||||
anchor: null,
|
||||
messageSelector: '.js-filter-message',
|
||||
debounceWait: 250,
|
||||
minLength: 1,
|
||||
maxLength: CONSTANTS.MAX_INPUT_LENGTH,
|
||||
caseSensitive: false,
|
||||
onFilter: null,
|
||||
onReset: null,
|
||||
onError: null
|
||||
};
|
||||
|
||||
// Validate options
|
||||
this.settings = Object.assign({}, defaults, this._validateOptions(options));
|
||||
|
||||
this.anchor = typeof this.settings.anchor === 'string' ?
|
||||
document.querySelector(this.settings.anchor) : this.settings.anchor;
|
||||
|
||||
// Initialize cache with size monitoring
|
||||
this._cache = {
|
||||
items: null,
|
||||
titles: null,
|
||||
messageElement: null,
|
||||
size: 0
|
||||
};
|
||||
|
||||
this._errorCount = 0;
|
||||
this._lastErrorTime = 0;
|
||||
|
||||
this.init();
|
||||
} catch (error) {
|
||||
console.error('ListFilter constructor error:', error);
|
||||
if (options && typeof options.onError === 'function') {
|
||||
options.onError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin prototype with improved methods
|
||||
ListFilter.prototype = {
|
||||
_validateOptions: function(options) {
|
||||
if (!options) return {};
|
||||
|
||||
var validated = {};
|
||||
try {
|
||||
if (typeof options !== 'object') {
|
||||
throw new Error('Options must be an object');
|
||||
}
|
||||
|
||||
// Validate each option
|
||||
if (options.minLength !== undefined) {
|
||||
validated.minLength = Math.max(1, parseInt(options.minLength, 10) || 1);
|
||||
}
|
||||
|
||||
if (options.maxLength !== undefined) {
|
||||
validated.maxLength = Math.min(
|
||||
CONSTANTS.MAX_INPUT_LENGTH,
|
||||
parseInt(options.maxLength, 10) || CONSTANTS.MAX_INPUT_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
if (options.debounceWait !== undefined) {
|
||||
validated.debounceWait = Math.max(50, parseInt(options.debounceWait, 10) || 250);
|
||||
}
|
||||
|
||||
validated.caseSensitive = !!options.caseSensitive;
|
||||
|
||||
// Validate callbacks
|
||||
if (options.onFilter && typeof options.onFilter === 'function') {
|
||||
validated.onFilter = options.onFilter;
|
||||
}
|
||||
|
||||
if (options.onReset && typeof options.onReset === 'function') {
|
||||
validated.onReset = options.onReset;
|
||||
}
|
||||
|
||||
if (options.onError && typeof options.onError === 'function') {
|
||||
validated.onError = options.onError;
|
||||
}
|
||||
|
||||
// Validate selectors
|
||||
if (options.messageSelector && typeof options.messageSelector === 'string') {
|
||||
validated.messageSelector = options.messageSelector;
|
||||
}
|
||||
|
||||
if (options.anchor) {
|
||||
validated.anchor = options.anchor;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Options validation error:', error);
|
||||
return {};
|
||||
}
|
||||
|
||||
return validated;
|
||||
},
|
||||
|
||||
init: function() {
|
||||
try {
|
||||
if (this.anchor) {
|
||||
this.anchor.classList.add(SELECTORS.LIST_FILTER);
|
||||
} else {
|
||||
this.list.classList.add(SELECTORS.LIST_FILTER);
|
||||
}
|
||||
|
||||
// Pre-cache elements with validation
|
||||
var items = this.list.getElementsByTagName('a');
|
||||
var titles = this.list.getElementsByTagName('li');
|
||||
|
||||
if (!items.length || !titles.length) {
|
||||
throw new Error('No filterable items found in the list');
|
||||
}
|
||||
|
||||
this._cache.items = items;
|
||||
this._cache.titles = titles;
|
||||
this._cache.messageElement = document.querySelector(this.settings.messageSelector);
|
||||
this._updateCacheSize();
|
||||
|
||||
this.bindEvents();
|
||||
} catch (error) {
|
||||
this._handleError(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
_updateCacheSize: function() {
|
||||
try {
|
||||
this._cache.size = 0;
|
||||
if (this._cache.items) this._cache.size += this._cache.items.length;
|
||||
if (this._cache.titles) this._cache.size += this._cache.titles.length;
|
||||
|
||||
// Clear fuzzy match cache if total cache size is too large
|
||||
if (this._cache.size > CONSTANTS.MAX_CACHE_SIZE) {
|
||||
fuzzyMatchCache.clear();
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error);
|
||||
}
|
||||
},
|
||||
|
||||
_handleError: function(error) {
|
||||
console.error('ListFilter error:', error);
|
||||
|
||||
var now = Date.now();
|
||||
if (now - this._lastErrorTime > 60000) { // Reset error count after 1 minute
|
||||
this._errorCount = 0;
|
||||
}
|
||||
|
||||
this._errorCount++;
|
||||
this._lastErrorTime = now;
|
||||
|
||||
if (this.settings.onError) {
|
||||
this.settings.onError(error);
|
||||
}
|
||||
|
||||
// Auto-recovery if too many errors
|
||||
if (this._errorCount > 10) {
|
||||
this.reset();
|
||||
this._errorCount = 0;
|
||||
}
|
||||
},
|
||||
|
||||
bindEvents: function() {
|
||||
try {
|
||||
var self = this;
|
||||
this.handleFilter = debounce(this.filter.bind(this), this.settings.debounceWait);
|
||||
|
||||
// Use passive event listeners for better performance
|
||||
this.input.addEventListener('input', this._validateEvent.bind(this), { passive: true });
|
||||
this.input.addEventListener('change', this._validateEvent.bind(this), { passive: true });
|
||||
|
||||
// Add ESC key handler
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' || e.key === 'Esc') {
|
||||
self.reset();
|
||||
}
|
||||
});
|
||||
|
||||
if (this._cache.messageElement) {
|
||||
this._cache.messageElement.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
if (e.target === self._cache.messageElement || self._cache.messageElement.contains(e.target)) {
|
||||
// Get the Bootstrap tooltip instance and dispose of it
|
||||
var tooltip = bootstrap.Tooltip.getInstance(self._cache.messageElement);
|
||||
if (tooltip) {
|
||||
tooltip.dispose();
|
||||
// Reinitialize the tooltip
|
||||
new bootstrap.Tooltip(self._cache.messageElement);
|
||||
}
|
||||
self.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle window resize for responsive layouts
|
||||
this._resizeHandler = debounce(function() {
|
||||
if (self.input.value.length > self.settings.minLength) {
|
||||
self.filter();
|
||||
}
|
||||
}, 250);
|
||||
|
||||
window.addEventListener('resize', this._resizeHandler, { passive: true });
|
||||
|
||||
// Add input maxlength attribute
|
||||
this.input.setAttribute('maxlength', this.settings.maxLength.toString());
|
||||
} catch (error) {
|
||||
this._handleError(error);
|
||||
}
|
||||
},
|
||||
|
||||
_validateEvent: function(event) {
|
||||
try {
|
||||
// Validate event source
|
||||
if (!event || !(event instanceof Event) || event.target !== this.input) {
|
||||
return;
|
||||
}
|
||||
|
||||
var value = this.input.value;
|
||||
|
||||
// Validate input length
|
||||
if (value.length > this.settings.maxLength) {
|
||||
this.input.value = value.slice(0, this.settings.maxLength);
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleFilter(event);
|
||||
} catch (error) {
|
||||
this._handleError(error);
|
||||
}
|
||||
},
|
||||
|
||||
findNavTitle: function(element) {
|
||||
try {
|
||||
if (!element || !(element instanceof Element)) {
|
||||
throw new Error('Invalid element provided to findNavTitle');
|
||||
}
|
||||
|
||||
var current = element;
|
||||
while (current && !current.classList.contains(SELECTORS.NAV_MENU)) {
|
||||
var prev = current.previousElementSibling;
|
||||
while (prev) {
|
||||
if (prev.classList.contains(SELECTORS.NAV_TITLE)) {
|
||||
return prev;
|
||||
}
|
||||
prev = prev.previousElementSibling;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
this._handleError(error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
showParents: function(element) {
|
||||
try {
|
||||
if (!element || !(element instanceof Element)) {
|
||||
throw new Error('Invalid element provided to showParents');
|
||||
}
|
||||
|
||||
var parent = element.parentElement;
|
||||
while (parent && !parent.classList.contains(SELECTORS.NAV_MENU)) {
|
||||
if (parent.tagName === 'LI') {
|
||||
parent.classList.remove(SELECTORS.FILTER_HIDE);
|
||||
parent.classList.add(SELECTORS.FILTER_SHOW);
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error);
|
||||
}
|
||||
},
|
||||
|
||||
showChildren: function(element) {
|
||||
try {
|
||||
if (!element || !(element instanceof Element)) {
|
||||
throw new Error('Invalid element provided to showChildren');
|
||||
}
|
||||
|
||||
var children = element.querySelectorAll('li');
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
var child = children[i];
|
||||
if (!child.classList.contains(SELECTORS.NAV_TITLE)) {
|
||||
child.classList.remove(SELECTORS.FILTER_HIDE);
|
||||
child.classList.add(SELECTORS.FILTER_SHOW);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error);
|
||||
}
|
||||
},
|
||||
|
||||
filter: function() {
|
||||
try {
|
||||
var filter = this.input.value;
|
||||
|
||||
// Input validation
|
||||
if (filter.length > this.settings.maxLength) {
|
||||
filter = filter.slice(0, this.settings.maxLength);
|
||||
this.input.value = filter;
|
||||
}
|
||||
|
||||
if (!this.settings.caseSensitive) {
|
||||
filter = filter.toLowerCase();
|
||||
}
|
||||
|
||||
if (filter.length > this.settings.minLength) {
|
||||
// Add active class when filter is active
|
||||
this.list.classList.add(SELECTORS.LIST_ACTIVE);
|
||||
this._performFilter(filter);
|
||||
} else {
|
||||
// Remove active class and reset
|
||||
this.list.classList.remove(SELECTORS.LIST_ACTIVE);
|
||||
this._resetFilter();
|
||||
}
|
||||
|
||||
// Callback
|
||||
if (typeof this.settings.onFilter === 'function') {
|
||||
this.settings.onFilter.call(this, filter);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error);
|
||||
this._resetFilter(); // Recover from error
|
||||
}
|
||||
},
|
||||
|
||||
_performFilter: function(filter) {
|
||||
try {
|
||||
// Hide all items first
|
||||
for (var i = 0; i < this._cache.titles.length; i++) {
|
||||
this._cache.titles[i].classList.remove(SELECTORS.FILTER_SHOW);
|
||||
this._cache.titles[i].classList.add(SELECTORS.FILTER_HIDE);
|
||||
}
|
||||
|
||||
var matchCount = 0;
|
||||
var matchedElements = new Set();
|
||||
var relevantTitles = new Set();
|
||||
var matches = [];
|
||||
|
||||
// Collect matches with limit
|
||||
for (var j = 0; j < this._cache.items.length && matches.length < CONSTANTS.MAX_MATCHES; j++) {
|
||||
var item = this._cache.items[j];
|
||||
var text = getNavItemText(item);
|
||||
var title = item.getAttribute('title') || '';
|
||||
|
||||
var textScore = fuzzyMatch(filter, text);
|
||||
var titleScore = fuzzyMatch(filter, title);
|
||||
var score = Math.max(textScore, titleScore);
|
||||
|
||||
if (score > 0) {
|
||||
matches.push({
|
||||
element: item,
|
||||
score: score
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score
|
||||
matches.sort(function(a, b) { return b.score - a.score; });
|
||||
|
||||
// Process matches
|
||||
for (var k = 0; k < matches.length; k++) {
|
||||
var match = matches[k];
|
||||
var parentLi = match.element.closest('li');
|
||||
if (parentLi && !matchedElements.has(parentLi)) {
|
||||
parentLi.classList.remove(SELECTORS.FILTER_HIDE);
|
||||
parentLi.classList.add(SELECTORS.FILTER_SHOW);
|
||||
this.showParents(parentLi);
|
||||
this.showChildren(parentLi);
|
||||
|
||||
var navTitle = this.findNavTitle(parentLi);
|
||||
if (navTitle && !relevantTitles.has(navTitle)) {
|
||||
navTitle.classList.remove(SELECTORS.FILTER_HIDE);
|
||||
navTitle.classList.add(SELECTORS.FILTER_SHOW);
|
||||
relevantTitles.add(navTitle);
|
||||
}
|
||||
|
||||
matchedElements.add(parentLi);
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update message
|
||||
if (this._cache.messageElement) {
|
||||
this._cache.messageElement.innerHTML = matchCount;
|
||||
this._cache.messageElement.style.cursor = 'pointer';
|
||||
}
|
||||
|
||||
// Clear match cache if it's too large
|
||||
if (fuzzyMatchCache.size > CONSTANTS.MAX_CACHE_SIZE) {
|
||||
fuzzyMatchCache.clear();
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error);
|
||||
this._resetFilter(); // Recover from error
|
||||
}
|
||||
},
|
||||
|
||||
_resetFilter: function() {
|
||||
try {
|
||||
// Remove active class when resetting
|
||||
this.list.classList.remove(SELECTORS.LIST_ACTIVE);
|
||||
|
||||
for (var i = 0; i < this._cache.titles.length; i++) {
|
||||
this._cache.titles[i].classList.remove(SELECTORS.FILTER_HIDE, SELECTORS.FILTER_SHOW);
|
||||
}
|
||||
|
||||
if (this._cache.messageElement) {
|
||||
this._cache.messageElement.textContent = '';
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error);
|
||||
}
|
||||
},
|
||||
|
||||
reset: function() {
|
||||
try {
|
||||
this.input.value = '';
|
||||
this._resetFilter();
|
||||
|
||||
// Callback
|
||||
if (typeof this.settings.onReset === 'function') {
|
||||
this.settings.onReset.call(this);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error);
|
||||
}
|
||||
},
|
||||
|
||||
// Public method to destroy the plugin and clean up
|
||||
destroy: function() {
|
||||
try {
|
||||
// Remove event listeners
|
||||
this.input.removeEventListener('input', this._validateEvent);
|
||||
this.input.removeEventListener('change', this._validateEvent);
|
||||
window.removeEventListener('resize', this._resizeHandler);
|
||||
|
||||
if (this._cache.messageElement) {
|
||||
this._cache.messageElement.removeEventListener('click', this.reset);
|
||||
}
|
||||
|
||||
// Reset state
|
||||
this._resetFilter();
|
||||
|
||||
// Clear caches
|
||||
fuzzyMatchCache.clear();
|
||||
this._cache = null;
|
||||
|
||||
// Remove attributes
|
||||
this.input.removeAttribute('maxlength');
|
||||
|
||||
// Remove classes including active class
|
||||
if (this.anchor) {
|
||||
this.anchor.classList.remove(SELECTORS.LIST_FILTER, SELECTORS.LIST_ACTIVE);
|
||||
} else {
|
||||
this.list.classList.remove(SELECTORS.LIST_FILTER, SELECTORS.LIST_ACTIVE);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add to global namespace
|
||||
window.ListFilter = ListFilter;
|
||||
})(window);
|
||||
@@ -0,0 +1,764 @@
|
||||
/**
|
||||
* Navigation v1.6.1
|
||||
* Enhanced secure version with improved performance, accessibility, usability,
|
||||
* security, and maintainability - Fixed null error and adjusted to flex display
|
||||
*/
|
||||
class Navigation {
|
||||
static instances = new WeakMap();
|
||||
|
||||
static getInstance(element) {
|
||||
const el = typeof element === 'string' ? document.querySelector(element) : element;
|
||||
return el instanceof Element ? Navigation.instances.get(el) : null;
|
||||
}
|
||||
|
||||
static initAll(selector, options = {}) {
|
||||
if (typeof selector !== 'string') throw new Error('Invalid selector');
|
||||
return Array.from(document.querySelectorAll(selector))
|
||||
.map(element => new Navigation(element, options));
|
||||
}
|
||||
|
||||
static initOnLoad(options = {}) {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
Navigation.initAll('.navigation', options);
|
||||
});
|
||||
}
|
||||
|
||||
static destroyAll() {
|
||||
Navigation.instances.forEach(instance => instance.destroy());
|
||||
}
|
||||
|
||||
static sanitizeHTML(str) {
|
||||
if (typeof str !== 'string') return '';
|
||||
const temp = document.createElement('div');
|
||||
temp.textContent = str;
|
||||
return temp.innerHTML.replace(/(on\w+)=["'][^"']*["']/gi, '');
|
||||
}
|
||||
|
||||
static isValidUrl(url) {
|
||||
if (typeof url !== 'string') return false;
|
||||
if (url === '#') return true;
|
||||
try {
|
||||
new URL(url, window.location.origin);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(element, options = {}) {
|
||||
if (!(element instanceof Element)) throw new Error('Invalid element provided');
|
||||
if (!element.querySelector('ul')) throw new Error('Navigation element must contain a <ul>');
|
||||
|
||||
this.defaults = Object.freeze({
|
||||
accordion: true,
|
||||
slideUpSpeed: 200,
|
||||
slideDownSpeed: 200,
|
||||
closedSign: '<i class="sa sa-chevron-down"></i>',
|
||||
openedSign: '<i class="sa sa-chevron-up"></i>',
|
||||
initClass: 'js-nav-built',
|
||||
debug: false,
|
||||
instanceId: `nav-${this.generateUniqueId()}`,
|
||||
maxDepth: 10,
|
||||
sanitize: true,
|
||||
animationTiming: 'easeOutExpo',
|
||||
debounceTime: 0,
|
||||
onError: null,
|
||||
showMore: {
|
||||
enabled: true,
|
||||
itemsToShow: 15, //has a bug when you show parent in upper tree
|
||||
minItemsForToggle: 2,
|
||||
moreText: 'Show {count} more',
|
||||
lessText: 'Show less'
|
||||
}
|
||||
});
|
||||
|
||||
this.options = Object.freeze(this.validateOptions({...this.defaults, ...options}));
|
||||
this.element = element;
|
||||
this.state = new Proxy({
|
||||
isInitialized: false,
|
||||
lastInteraction: 0,
|
||||
activeSubmenu: null,
|
||||
isAnimating: false
|
||||
}, {
|
||||
set: (target, prop, value) => {
|
||||
target[prop] = value;
|
||||
this._debug(`State updated: ${prop}`, 'info', {value});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
this.cache = new WeakMap();
|
||||
if (Navigation.instances.has(element)) return Navigation.instances.get(element);
|
||||
Navigation.instances.set(element, this);
|
||||
|
||||
this.handleClick = this.debounce(this.handleClick.bind(this), this.options.debounceTime);
|
||||
this.handleKeydown = this.handleKeydown.bind(this);
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
validateOptions(options) {
|
||||
const validators = {
|
||||
accordion: v => typeof v === 'boolean',
|
||||
slideUpSpeed: v => typeof v === 'number' && v >= 0 && v <= 1000,
|
||||
slideDownSpeed: v => typeof v === 'number' && v >= 0 && v <= 1000,
|
||||
closedSign: v => typeof v === 'string' && !/<script/i.test(v),
|
||||
openedSign: v => typeof v === 'string' && !/<script/i.test(v),
|
||||
initClass: v => typeof v === 'string',
|
||||
debug: v => typeof v === 'boolean',
|
||||
maxDepth: v => typeof v === 'number' && v > 0,
|
||||
sanitize: v => typeof v === 'boolean',
|
||||
animationTiming: v => typeof v === 'string',
|
||||
debounceTime: v => typeof v === 'number' && v >= 0,
|
||||
onError: v => typeof v === 'function' || v === null
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(options)) {
|
||||
if (!(key in validators)) continue;
|
||||
if (!validators[key](value)) {
|
||||
throw new TypeError(`Invalid ${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
_debug(message, level = 'log', data = {}) {
|
||||
if (!this.options.debug) return;
|
||||
const timestamp = new Date().toISOString();
|
||||
const secureData = JSON.parse(JSON.stringify(data, (k, v) =>
|
||||
typeof v === 'string' ? Navigation.sanitizeHTML(v) : v));
|
||||
const prefix = `[Navigation ${this.options.instanceId} ${timestamp}]`;
|
||||
const logMethod = {error: console.error, warn: console.warn, info: console.info}[level] || console.log;
|
||||
logMethod(prefix, message, secureData, level === 'error' ? new Error().stack : undefined);
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.state.isInitialized) return this._debug('Already initialized', 'warn');
|
||||
if (!this.element) throw new Error('Navigation element is null');
|
||||
try {
|
||||
this._debug('Initializing navigation', 'info');
|
||||
Object.assign(this.element, {
|
||||
role: 'navigation',
|
||||
tabIndex: 0,
|
||||
'aria-label': 'Main navigation'
|
||||
});
|
||||
// Find the top-level menu
|
||||
const topMenu = this.element.querySelector('ul');
|
||||
if (topMenu) {
|
||||
// Set the top-level menu to have role="menu"
|
||||
topMenu.setAttribute('role', 'menu');
|
||||
}
|
||||
this.element.classList.add(this.options.initClass);
|
||||
this.element.dataset.instanceId = this.options.instanceId;
|
||||
this.setupSecureCache();
|
||||
this.setupTitleSeparators();
|
||||
this.setupMenuItems();
|
||||
this.setupActiveItems();
|
||||
|
||||
// Ensure at least one menu is visible if none are currently displayed
|
||||
this._ensureMenuVisibility();
|
||||
|
||||
this.bindSecureEvents();
|
||||
this.state.isInitialized = true;
|
||||
} catch (error) {
|
||||
this._debug('Initialization failed', 'error', {error});
|
||||
this.options.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
setupSecureCache() {
|
||||
const cache = this.buildCache(this.element);
|
||||
if (this.validateDOMStructure(cache)) {
|
||||
this.cache.set(this.element, cache);
|
||||
} else {
|
||||
throw new Error('Invalid DOM structure');
|
||||
}
|
||||
}
|
||||
|
||||
buildCache(root) {
|
||||
const items = [], links = [], subMenus = [];
|
||||
const traverse = (el, depth = 0) => {
|
||||
if (depth > this.options.maxDepth) return;
|
||||
if (el.tagName === 'LI') items.push(el);
|
||||
if (el.tagName === 'A') links.push(el);
|
||||
if (el.tagName === 'UL') subMenus.push(el);
|
||||
Array.from(el.children).forEach(child => traverse(child, depth + 1));
|
||||
};
|
||||
traverse(root);
|
||||
return {items, links, subMenus};
|
||||
}
|
||||
|
||||
validateDOMStructure(cache) {
|
||||
return cache.items.every(item => this.getElementDepth(item) <= this.options.maxDepth) &&
|
||||
cache.links.every(link => Navigation.isValidUrl(link.getAttribute('href') || '#'));
|
||||
}
|
||||
|
||||
getElementDepth(element) {
|
||||
let depth = 0, current = element;
|
||||
while (current && current !== this.element && depth <= this.options.maxDepth) {
|
||||
depth++;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
setupMenuItems() {
|
||||
const cache = this.cache.get(this.element);
|
||||
const showMoreConfig = this.options.showMore;
|
||||
|
||||
if (!showMoreConfig?.enabled) return;
|
||||
|
||||
const ITEMS_TO_SHOW = showMoreConfig.itemsToShow || 15;
|
||||
const MIN_ITEMS_FOR_TOGGLE = showMoreConfig.minItemsForToggle || 2;
|
||||
|
||||
cache?.items.forEach(item => {
|
||||
// Add proper accessibility attributes to nav-title items
|
||||
if (item.classList.contains('nav-title')) {
|
||||
const titleSpan = item.querySelector('span');
|
||||
const titleText = titleSpan?.textContent || 'Section';
|
||||
|
||||
// Set role="separator" and aria-label with the title text
|
||||
item.setAttribute('role', 'separator');
|
||||
item.setAttribute('aria-label', titleText);
|
||||
|
||||
// Remove menuitem role from nav-title items since they're separators
|
||||
return;
|
||||
}
|
||||
|
||||
const subMenu = item.querySelector('ul');
|
||||
if (subMenu && this.getElementDepth(subMenu) <= this.options.maxDepth) {
|
||||
item.classList.add('has-ul');
|
||||
Object.assign(subMenu, {
|
||||
role: 'menu',
|
||||
'data-depth': this.getElementDepth(subMenu)
|
||||
// Remove the display: none so menus are visible by default
|
||||
});
|
||||
|
||||
// Only hide the menu if explicitly needed
|
||||
if (!item.classList.contains('active') && !this._hasActiveDescendant(item)) {
|
||||
subMenu.style.display = 'none';
|
||||
} else {
|
||||
// If item is active or has active children, show it
|
||||
subMenu.style.display = 'flex';
|
||||
subMenu.style.height = 'auto';
|
||||
item.classList.add('open');
|
||||
item.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
// Filter out parent items (items with submenus) from being hidden
|
||||
const children = Array.from(subMenu.children);
|
||||
const nonParentItems = children.filter(child => !child.querySelector('ul'));
|
||||
const parentItems = children.filter(child => child.querySelector('ul'));
|
||||
|
||||
// Calculate remaining items only from non-parent items
|
||||
const visibleNonParentItems = nonParentItems.slice(0, ITEMS_TO_SHOW);
|
||||
const hiddenNonParentItems = nonParentItems.slice(ITEMS_TO_SHOW);
|
||||
const remainingItems = hiddenNonParentItems.length;
|
||||
|
||||
if (remainingItems >= MIN_ITEMS_FOR_TOGGLE) {
|
||||
// Create container for hidden items
|
||||
const hiddenContainer = document.createElement('div');
|
||||
hiddenContainer.className = 'nav-hidden-container';
|
||||
|
||||
// Check if any hidden items are active
|
||||
const hasActiveItem = hiddenNonParentItems.some(child =>
|
||||
child.classList.contains('active')
|
||||
);
|
||||
|
||||
// Set initial state based on active items
|
||||
if (hasActiveItem) {
|
||||
hiddenContainer.style.display = 'flex';
|
||||
hiddenContainer.style.height = 'auto';
|
||||
} else {
|
||||
hiddenContainer.style.display = 'none';
|
||||
hiddenContainer.style.height = '0';
|
||||
}
|
||||
hiddenContainer.style.overflow = 'hidden';
|
||||
|
||||
// Reorder items: visible non-parents, parent items, then hidden non-parents
|
||||
subMenu.innerHTML = ''; // Clear submenu
|
||||
visibleNonParentItems.forEach(child => subMenu.appendChild(child));
|
||||
parentItems.forEach(child => subMenu.appendChild(child));
|
||||
hiddenNonParentItems.forEach(child => hiddenContainer.appendChild(child));
|
||||
subMenu.appendChild(hiddenContainer);
|
||||
|
||||
// Create "Show more" link with collapse sign
|
||||
const showMoreLi = document.createElement('li');
|
||||
showMoreLi.className = 'nav-show-more';
|
||||
showMoreLi.setAttribute('role', 'presentation');
|
||||
|
||||
const showMoreLink = document.createElement('a');
|
||||
showMoreLink.href = '#';
|
||||
showMoreLink.className = 'nav-more-link';
|
||||
showMoreLink.setAttribute('role', 'button');
|
||||
showMoreLink.setAttribute('aria-expanded', hasActiveItem ? 'true' : 'false');
|
||||
showMoreLink.setAttribute('aria-controls', `nav-toggle-${this.generateUniqueId().substring(0, 8)}`);
|
||||
|
||||
// Add text and collapse sign
|
||||
const textSpan = document.createElement('span');
|
||||
const moreText = showMoreConfig.moreText.replace('{count}', remainingItems);
|
||||
textSpan.textContent = hasActiveItem ? showMoreConfig.lessText : moreText;
|
||||
showMoreLink.appendChild(textSpan);
|
||||
|
||||
const collapseSign = document.createElement('span');
|
||||
collapseSign.className = 'collapse-sign';
|
||||
collapseSign.setAttribute('aria-hidden', 'true');
|
||||
const icon = document.createElement('i');
|
||||
icon.className = hasActiveItem ? 'sa sa-chevron-up' : 'sa sa-chevron-down';
|
||||
collapseSign.appendChild(icon);
|
||||
showMoreLink.appendChild(collapseSign);
|
||||
|
||||
if (hasActiveItem) {
|
||||
showMoreLink.classList.add('showing-more');
|
||||
}
|
||||
|
||||
showMoreLi.appendChild(showMoreLink);
|
||||
subMenu.appendChild(showMoreLi);
|
||||
|
||||
// Set ID on the hidden container for aria-controls reference
|
||||
const containerId = showMoreLink.getAttribute('aria-controls');
|
||||
hiddenContainer.id = containerId;
|
||||
|
||||
// Add click handler with animation
|
||||
showMoreLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const isShowingMore = showMoreLink.classList.contains('showing-more');
|
||||
|
||||
if (isShowingMore) {
|
||||
this.animateHeight(hiddenContainer, 'up', () => {
|
||||
hiddenContainer.style.display = 'none';
|
||||
textSpan.textContent = moreText;
|
||||
showMoreLink.classList.remove('showing-more');
|
||||
showMoreLink.setAttribute('aria-expanded', 'false');
|
||||
icon.className = 'sa sa-chevron-down';
|
||||
});
|
||||
} else {
|
||||
hiddenContainer.style.display = 'flex';
|
||||
this.animateHeight(hiddenContainer, 'down', () => {
|
||||
textSpan.textContent = showMoreConfig.lessText;
|
||||
showMoreLink.classList.add('showing-more');
|
||||
showMoreLink.setAttribute('aria-expanded', 'true');
|
||||
icon.className = 'sa sa-chevron-up';
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const link = item.querySelector('a:first-child');
|
||||
if (link) this.setupSecureLink(link);
|
||||
}
|
||||
// Only set menuitem role for non-separator items
|
||||
if (!item.classList.contains('nav-title')) {
|
||||
item.role = 'menuitem';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupSecureLink(link) {
|
||||
// Create collapse sign container
|
||||
const collapseSign = document.createElement('span');
|
||||
collapseSign.className = 'collapse-sign';
|
||||
collapseSign.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// Create icon element
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'sa sa-chevron-down';
|
||||
collapseSign.appendChild(icon);
|
||||
|
||||
// Get the parent li element
|
||||
const parentLi = link.closest('li');
|
||||
|
||||
// Set ARIA attributes on the LI instead of the link
|
||||
if (parentLi) {
|
||||
parentLi.setAttribute('aria-expanded', 'false');
|
||||
parentLi.setAttribute('aria-haspopup', 'true');
|
||||
}
|
||||
|
||||
// Add click prevention for empty links
|
||||
if (link.getAttribute('href') === '#') {
|
||||
link.addEventListener('click', e => e.preventDefault());
|
||||
}
|
||||
|
||||
link.appendChild(collapseSign);
|
||||
}
|
||||
|
||||
setupActiveItems() {
|
||||
const cache = this.cache.get(this.element);
|
||||
|
||||
// Process all active items to set up expanded state
|
||||
cache?.items.forEach(item => {
|
||||
if (item.classList.contains('active')) {
|
||||
// First expand all parent ULs
|
||||
let parent = item.parentElement;
|
||||
while (parent && parent !== this.element) {
|
||||
if (parent.tagName === 'UL') {
|
||||
parent.style.display = 'flex';
|
||||
parent.style.height = 'auto';
|
||||
|
||||
// Handle the parent LI
|
||||
const parentLi = parent.parentElement;
|
||||
if (parentLi && parentLi.tagName === 'LI') {
|
||||
parentLi.classList.add('open', 'has-ul');
|
||||
|
||||
// Set ARIA attributes on the LI
|
||||
parentLi.setAttribute('aria-expanded', 'true');
|
||||
parentLi.setAttribute('aria-haspopup', 'true');
|
||||
|
||||
const parentLink = parentLi.querySelector('a');
|
||||
if (parentLink) {
|
||||
const collapseSign = parentLink.querySelector('.collapse-sign i');
|
||||
if (collapseSign) {
|
||||
collapseSign.className = 'sa sa-chevron-up';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
// Then handle the active item's own submenu if it exists
|
||||
const submenu = item.querySelector('ul');
|
||||
if (submenu) {
|
||||
submenu.style.display = 'flex';
|
||||
submenu.style.height = 'auto';
|
||||
item.classList.add('open', 'has-ul');
|
||||
|
||||
// Set ARIA attributes on the LI
|
||||
item.setAttribute('aria-expanded', 'true');
|
||||
item.setAttribute('aria-haspopup', 'true');
|
||||
|
||||
const link = item.querySelector('a');
|
||||
if (link) {
|
||||
const collapseSign = link.querySelector('.collapse-sign i');
|
||||
if (collapseSign) {
|
||||
collapseSign.className = 'sa sa-chevron-up';
|
||||
}
|
||||
}
|
||||
this.state.activeSubmenu = submenu;
|
||||
} else {
|
||||
// This is a leaf item (no submenu)
|
||||
item.setAttribute('aria-current', 'page');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindSecureEvents() {
|
||||
this.element.removeEventListener('mousedown', this.handleClick);
|
||||
this.element.removeEventListener('keydown', this.handleKeydown);
|
||||
this.element.addEventListener('mousedown', this.handleClick, {passive: true});
|
||||
this.element.addEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
handleClick(event) {
|
||||
const link = event.target.closest('a');
|
||||
if (!link || !this.element.contains(link)) return;
|
||||
const li = link.parentElement;
|
||||
const ul = li.querySelector('ul');
|
||||
if (!ul) return;
|
||||
event.preventDefault();
|
||||
this.toggleSubmenu(li, link, ul);
|
||||
}
|
||||
|
||||
handleKeydown(event) {
|
||||
const link = event.target.closest('a');
|
||||
if (!link || !this.element.contains(link)) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
this.handleClick(event);
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
this.closeAllSubmenus();
|
||||
link.focus();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
this.navigateMenu(link, event.key === 'ArrowDown' ? 'next' : 'prev');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
toggleSubmenu(li, link, ul) {
|
||||
if (this.state.isAnimating) {
|
||||
this._debug('Animation in progress, ignoring toggle request');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the expanded state from the LI, not the link
|
||||
const isExpanded = li.getAttribute('aria-expanded') === 'true';
|
||||
|
||||
if (this.options.accordion) {
|
||||
this.handleAccordion(li);
|
||||
}
|
||||
|
||||
const icon = link.querySelector('.collapse-sign i');
|
||||
this.state.isAnimating = true;
|
||||
|
||||
if (isExpanded) {
|
||||
ul.style.display = 'flex';
|
||||
this.animateHeight(ul, 'up', () => {
|
||||
li.classList.remove('open');
|
||||
// Update aria-expanded on the LI
|
||||
li.setAttribute('aria-expanded', 'false');
|
||||
if (icon) {
|
||||
icon.className = 'sa sa-chevron-down';
|
||||
}
|
||||
this.state.activeSubmenu = null;
|
||||
this.state.isAnimating = false;
|
||||
});
|
||||
} else {
|
||||
ul.style.display = 'flex';
|
||||
this.animateHeight(ul, 'down', () => {
|
||||
li.classList.add('open');
|
||||
// Update aria-expanded on the LI
|
||||
li.setAttribute('aria-expanded', 'true');
|
||||
if (icon) {
|
||||
icon.className = 'sa sa-chevron-up';
|
||||
}
|
||||
this.state.activeSubmenu = ul;
|
||||
this.state.isAnimating = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
animateHeight(element, direction, callback) {
|
||||
const existingListener = element._transitionEndListener;
|
||||
if (existingListener) {
|
||||
element.removeEventListener('transitionend', existingListener);
|
||||
}
|
||||
|
||||
const duration = direction === 'up' ? this.options.slideUpSpeed : this.options.slideDownSpeed;
|
||||
const timing = this.options.animationTiming === 'easeOutExpo' ?
|
||||
'cubic-bezier(0.16, 1, 0.3, 1)' : this.options.animationTiming;
|
||||
|
||||
element.style.removeProperty('transition');
|
||||
element.style.removeProperty('height');
|
||||
element.style.removeProperty('overflow');
|
||||
|
||||
if (direction === 'down') {
|
||||
element.style.display = 'flex';
|
||||
element.style.overflow = 'hidden';
|
||||
element.style.height = '0';
|
||||
|
||||
element.offsetHeight;
|
||||
|
||||
const height = element.scrollHeight;
|
||||
element.style.transition = `height ${duration}ms ${timing}`;
|
||||
element.style.height = `${height}px`;
|
||||
|
||||
const onTransitionEnd = () => {
|
||||
element.removeEventListener('transitionend', onTransitionEnd);
|
||||
element._transitionEndListener = null;
|
||||
element.style.removeProperty('height');
|
||||
element.style.removeProperty('overflow');
|
||||
element.style.removeProperty('transition');
|
||||
callback();
|
||||
};
|
||||
|
||||
element._transitionEndListener = onTransitionEnd;
|
||||
element.addEventListener('transitionend', onTransitionEnd, {once: true});
|
||||
} else {
|
||||
element.style.overflow = 'hidden';
|
||||
element.style.height = `${element.scrollHeight}px`;
|
||||
|
||||
element.offsetHeight;
|
||||
|
||||
element.style.transition = `height ${duration}ms ${timing}`;
|
||||
element.style.height = '0';
|
||||
|
||||
const onTransitionEnd = () => {
|
||||
element.removeEventListener('transitionend', onTransitionEnd);
|
||||
element._transitionEndListener = null;
|
||||
element.style.display = 'none';
|
||||
element.style.removeProperty('height');
|
||||
element.style.removeProperty('overflow');
|
||||
element.style.removeProperty('transition');
|
||||
callback();
|
||||
};
|
||||
|
||||
element._transitionEndListener = onTransitionEnd;
|
||||
element.addEventListener('transitionend', onTransitionEnd, {once: true});
|
||||
}
|
||||
}
|
||||
|
||||
handleAccordion(activeLi) {
|
||||
const siblings = Array.from(activeLi.parentElement.children);
|
||||
siblings.forEach(sibling => {
|
||||
if (sibling !== activeLi) {
|
||||
const submenu = sibling.querySelector('ul');
|
||||
const link = sibling.querySelector('a');
|
||||
if (submenu && getComputedStyle(submenu).display !== 'none') {
|
||||
this.animateHeight(submenu, 'up', () => {
|
||||
sibling.classList.remove('open');
|
||||
// Update aria-expanded on the LI
|
||||
sibling.setAttribute('aria-expanded', 'false');
|
||||
const icon = link?.querySelector('.collapse-sign i');
|
||||
if (icon) {
|
||||
icon.className = 'sa sa-chevron-down';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
navigateMenu(currentLink, direction) {
|
||||
const links = Array.from(this.element.querySelectorAll('li a'));
|
||||
const currentIndex = links.indexOf(currentLink);
|
||||
const nextIndex = direction === 'next'
|
||||
? (currentIndex + 1) % links.length
|
||||
: (currentIndex - 1 + links.length) % links.length;
|
||||
links[nextIndex].focus();
|
||||
}
|
||||
|
||||
closeAllSubmenus() {
|
||||
const cache = this.cache.get(this.element);
|
||||
cache?.subMenus.forEach(submenu => {
|
||||
if (submenu.style.display !== 'none') {
|
||||
const parent = submenu.parentElement;
|
||||
this.animateHeight(submenu, 'up', () => {
|
||||
parent.classList.remove('open');
|
||||
// Update aria-expanded on the LI
|
||||
parent.setAttribute('aria-expanded', 'false');
|
||||
const link = parent.querySelector('a');
|
||||
if (link) {
|
||||
const icon = link.querySelector('.collapse-sign i');
|
||||
if (icon) {
|
||||
icon.className = 'sa sa-chevron-down';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
this.state.activeSubmenu = null;
|
||||
}
|
||||
|
||||
debounce(fn, wait) {
|
||||
let timeout;
|
||||
return (...args) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fn(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.state.isInitialized) return this._debug('Cannot destroy - not initialized', 'warn');
|
||||
try {
|
||||
this._debug('Starting destruction', 'info');
|
||||
this.element.removeEventListener('click', this.handleClick);
|
||||
this.element.removeEventListener('keydown', this.handleKeydown);
|
||||
this.resetItems();
|
||||
this.cleanLinks();
|
||||
this.resetSubmenus();
|
||||
this.element.removeAttribute('role');
|
||||
this.element.removeAttribute('tabIndex');
|
||||
this.element.removeAttribute('aria-label');
|
||||
this.element.removeAttribute('data-instance-id');
|
||||
this.element.classList.remove(this.options.initClass);
|
||||
this.cache.delete(this.element);
|
||||
Navigation.instances.delete(this.element);
|
||||
this.state.isInitialized = false;
|
||||
this._debug('Navigation destroyed', 'info');
|
||||
} catch (error) {
|
||||
this._debug('Destruction failed', 'error', {error});
|
||||
this.options.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
resetItems() {
|
||||
const cache = this.cache.get(this.element);
|
||||
cache?.items.forEach(item => {
|
||||
item.classList.remove('active', 'open');
|
||||
item.removeAttribute('role');
|
||||
});
|
||||
}
|
||||
|
||||
cleanLinks() {
|
||||
const cache = this.cache.get(this.element);
|
||||
cache?.links.forEach(link => {
|
||||
const newLink = link.cloneNode(true);
|
||||
link.parentNode?.replaceChild(newLink, link);
|
||||
newLink.classList.remove('active');
|
||||
const sign = newLink.querySelector('.collapse-sign');
|
||||
sign?.remove();
|
||||
});
|
||||
}
|
||||
|
||||
resetSubmenus() {
|
||||
const cache = this.cache.get(this.element);
|
||||
cache?.subMenus.forEach(menu => {
|
||||
menu.removeAttribute('style');
|
||||
menu.removeAttribute('role');
|
||||
});
|
||||
}
|
||||
|
||||
generateUniqueId() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
setupTitleSeparators() {
|
||||
// Find all nav-title items and add appropriate accessibility attributes
|
||||
const navTitles = this.element.querySelectorAll('li.nav-title');
|
||||
navTitles.forEach(item => {
|
||||
const titleSpan = item.querySelector('span');
|
||||
const titleText = titleSpan?.textContent || 'Section';
|
||||
|
||||
// Set role="separator" and aria-label with the title text
|
||||
item.setAttribute('role', 'separator');
|
||||
item.setAttribute('aria-label', titleText);
|
||||
|
||||
// Remove any previously set menuitem role
|
||||
item.removeAttribute('role');
|
||||
item.setAttribute('role', 'separator');
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to check if an item has any active descendant items
|
||||
_hasActiveDescendant(item) {
|
||||
return !!item.querySelector('li.active');
|
||||
}
|
||||
|
||||
// Ensures at least one menu is visible if none are currently displayed
|
||||
_ensureMenuVisibility() {
|
||||
const cache = this.cache.get(this.element);
|
||||
if (!cache) return;
|
||||
|
||||
// Check if any submenus are already visible
|
||||
const visibleSubmenu = cache.subMenus.find(menu =>
|
||||
menu.style.display === 'flex' || menu.style.display === 'block'
|
||||
);
|
||||
|
||||
// If no submenus are visible, show the first one
|
||||
if (!visibleSubmenu && cache.subMenus.length > 0) {
|
||||
const firstSubmenu = cache.subMenus[0];
|
||||
firstSubmenu.style.display = 'flex';
|
||||
firstSubmenu.style.height = 'auto';
|
||||
|
||||
const parentLi = firstSubmenu.closest('li');
|
||||
if (parentLi) {
|
||||
parentLi.classList.add('open', 'has-ul');
|
||||
parentLi.setAttribute('aria-expanded', 'true');
|
||||
|
||||
const link = parentLi.querySelector('a');
|
||||
if (link) {
|
||||
const collapseSign = link.querySelector('.collapse-sign i');
|
||||
if (collapseSign) {
|
||||
collapseSign.className = 'sa sa-chevron-up';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.state.activeSubmenu = firstSubmenu;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
/*!
|
||||
smartSlimscroll
|
||||
|
||||
Security:
|
||||
Added input sanitization for user-provided options
|
||||
Protected against potential XSS attacks
|
||||
Added bounds checking for touch/scroll inputs
|
||||
|
||||
Performance:
|
||||
Added event throttling for scroll and touch events
|
||||
Used passive event listeners where appropriate
|
||||
Implemented IntersectionObserver for visibility checks
|
||||
|
||||
Memory Management:
|
||||
Used WeakMap for private state management
|
||||
Added proper cleanup with a destroy method
|
||||
Improved event listener cleanup
|
||||
|
||||
Error Handling:
|
||||
Added try-catch blocks for error boundaries
|
||||
Improved error logging
|
||||
Added graceful degradation
|
||||
|
||||
Modern JavaScript Features:
|
||||
Used const/let instead of var
|
||||
Added optional chaining
|
||||
Used modern DOM APIs
|
||||
|
||||
Code Organization:
|
||||
Better separation of concerns
|
||||
More modular function structure
|
||||
Improved state management
|
||||
|
||||
*/
|
||||
(function (window, document) {
|
||||
'use strict';
|
||||
|
||||
var smartSlimScroll = function (selector, options) {
|
||||
// Handle multiple elements
|
||||
var elements = typeof selector === 'string' ?
|
||||
document.querySelectorAll(selector) :
|
||||
[selector];
|
||||
|
||||
// Create instance for each element
|
||||
for (var i = 0; i < elements.length; i++) {
|
||||
var element = elements[i];
|
||||
if (!element) {
|
||||
console.error('smartSlimScroll: Element not found');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already initialized
|
||||
if (element.getAttribute('data-slimscroll-initialized')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark as initialized
|
||||
element.setAttribute('data-slimscroll-initialized', 'true');
|
||||
|
||||
this.element = element;
|
||||
this.options = {
|
||||
width: 'auto',
|
||||
height: '250px',
|
||||
size: '7px',
|
||||
color: '#000',
|
||||
position: 'right',
|
||||
offsetX: '1px',
|
||||
offsetY: '0px',
|
||||
start: 'top',
|
||||
opacity: 0.4,
|
||||
fadeOutSpeed: 500,
|
||||
alwaysVisible: false,
|
||||
disableFadeOut: false,
|
||||
railVisible: false,
|
||||
railColor: '#333',
|
||||
railOpacity: 0.2,
|
||||
railClass: 'slimScrollRail',
|
||||
barClass: 'slimScrollBar',
|
||||
wrapperClass: 'slimScrollDiv',
|
||||
allowPageScroll: false,
|
||||
wheelStep: 20,
|
||||
touchScrollStep: 200,
|
||||
borderRadius: '7px',
|
||||
railBorderRadius: '7px'
|
||||
};
|
||||
|
||||
// Merge options
|
||||
for (var key in options) {
|
||||
if (options.hasOwnProperty(key)) {
|
||||
this.options[key] = options[key];
|
||||
}
|
||||
}
|
||||
|
||||
this.init();
|
||||
}
|
||||
};
|
||||
|
||||
smartSlimScroll.prototype = {
|
||||
init: function () {
|
||||
// Store this reference
|
||||
const self = this;
|
||||
|
||||
// Use WeakMap for private variables to prevent memory leaks
|
||||
const privateState = new WeakMap();
|
||||
privateState.set(self, {
|
||||
isOverPanel: false,
|
||||
isOverBar: false,
|
||||
isDragg: false,
|
||||
queueHide: null,
|
||||
touchDif: 0,
|
||||
barHeight: 0,
|
||||
percentScroll: 0,
|
||||
lastScroll: 0,
|
||||
minBarHeight: 30,
|
||||
releaseScroll: false,
|
||||
isScrolling: false,
|
||||
scrollTimeout: null,
|
||||
isOverRail: false,
|
||||
firstHover: true
|
||||
});
|
||||
|
||||
const o = this.options;
|
||||
const me = this.element;
|
||||
let wrapper, rail, bar;
|
||||
|
||||
// Guard against XSS by sanitizing user inputs
|
||||
const sanitizeInput = (input) => {
|
||||
return input.replace(/[<>]/g, '');
|
||||
};
|
||||
|
||||
// Sanitize user-provided options
|
||||
o.width = sanitizeInput(o.width);
|
||||
o.height = sanitizeInput(o.height);
|
||||
o.color = sanitizeInput(o.color);
|
||||
o.railColor = sanitizeInput(o.railColor);
|
||||
|
||||
// Use passive event listeners for better performance
|
||||
const passiveOptions = {
|
||||
passive: false // Allow preventDefault
|
||||
};
|
||||
|
||||
// Improved touch handling with bounds checking
|
||||
const handleTouchMove = (e) => {
|
||||
const state = privateState.get(self);
|
||||
if (!state.releaseScroll) {
|
||||
e.preventDefault(); // Now works with passive: false
|
||||
}
|
||||
|
||||
if (e.touches?.length) {
|
||||
const diff = (state.touchDif - e.touches[0].pageY) / o.touchScrollStep;
|
||||
scrollContent(Math.min(Math.max(diff, -50), 50), true);
|
||||
state.touchDif = e.touches[0].pageY;
|
||||
}
|
||||
};
|
||||
|
||||
// Improved mouse wheel handling
|
||||
const handleWheel = function (e) {
|
||||
const state = privateState.get(self);
|
||||
|
||||
if (!state.isOverPanel) return;
|
||||
|
||||
// Set scrolling state
|
||||
state.isScrolling = true;
|
||||
showBar();
|
||||
|
||||
// Clear any existing scroll timeout
|
||||
clearTimeout(state.scrollTimeout);
|
||||
|
||||
// Normalize delta
|
||||
let delta = 0;
|
||||
if (e.wheelDelta) {
|
||||
delta = -e.wheelDelta / 120;
|
||||
}
|
||||
if (e.detail) {
|
||||
delta = e.detail / 3;
|
||||
}
|
||||
if (e.deltaY) {
|
||||
delta = e.deltaY / 120;
|
||||
}
|
||||
|
||||
// Calculate scroll boundaries
|
||||
const contentHeight = me.scrollHeight;
|
||||
const visibleHeight = wrapper.offsetHeight;
|
||||
const scrollTop = me.scrollTop;
|
||||
const maxScroll = contentHeight - visibleHeight;
|
||||
|
||||
// Determine scroll direction and boundaries
|
||||
const scrollingUp = delta < 0;
|
||||
const scrollingDown = delta > 0;
|
||||
const atTop = scrollTop <= 0;
|
||||
const atBottom = scrollTop >= maxScroll;
|
||||
|
||||
// Check if scroll should be prevented
|
||||
const shouldPreventScroll =
|
||||
(scrollingUp && !atTop) ||
|
||||
(scrollingDown && !atBottom) ||
|
||||
(contentHeight > visibleHeight);
|
||||
|
||||
if (shouldPreventScroll) {
|
||||
// Stop all propagation and prevent default
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.returnValue = false;
|
||||
|
||||
// Apply the scroll
|
||||
scrollContent(delta, true);
|
||||
}
|
||||
|
||||
// Set timeout to clear scrolling state
|
||||
state.scrollTimeout = setTimeout(function () {
|
||||
state.isScrolling = false;
|
||||
hideBar();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Use Intersection Observer for visibility
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
showBar();
|
||||
} else {
|
||||
hideBar();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(me);
|
||||
|
||||
// Clean up function
|
||||
const cleanup = () => {
|
||||
observer.disconnect();
|
||||
wrapper.removeEventListener('touchmove', handleTouchMove);
|
||||
wrapper.removeEventListener('wheel', handleWheel);
|
||||
// ... remove other event listeners
|
||||
};
|
||||
|
||||
// Store cleanup function for potential future use
|
||||
privateState.get(self).cleanup = cleanup;
|
||||
|
||||
// Add error boundary
|
||||
try {
|
||||
// Ensure we are not binding it again
|
||||
if (me.parentNode.classList.contains(o.wrapperClass)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create wrapper
|
||||
wrapper = document.createElement('div');
|
||||
wrapper.className = o.wrapperClass;
|
||||
wrapper.style.position = 'relative';
|
||||
wrapper.style.overflow = 'hidden';
|
||||
wrapper.style.width = o.width;
|
||||
wrapper.style.height = o.height;
|
||||
|
||||
// Update style for the div
|
||||
me.style.overflow = 'hidden';
|
||||
me.style.width = o.width;
|
||||
me.style.height = o.height;
|
||||
|
||||
// Create scrollbar
|
||||
bar = document.createElement('div');
|
||||
bar.className = o.barClass;
|
||||
bar.style.position = 'absolute';
|
||||
bar.style.top = o.offsetY;
|
||||
bar.style.width = o.size;
|
||||
bar.style.opacity = '0';
|
||||
bar.style.background = o.color;
|
||||
bar.style.borderRadius = o.borderRadius;
|
||||
bar.style.transition = 'opacity 0.2s linear';
|
||||
bar.style.zIndex = '99';
|
||||
|
||||
// Create rail
|
||||
rail = document.createElement('div');
|
||||
rail.className = o.railClass;
|
||||
rail.style.position = 'absolute';
|
||||
rail.style.top = o.offsetY;
|
||||
rail.style.width = o.size;
|
||||
rail.style.height = `calc(100% - ${2 * parseInt(o.offsetY)}px)`;
|
||||
rail.style.opacity = '0';
|
||||
rail.style.background = o.railColor;
|
||||
rail.style.borderRadius = o.railBorderRadius;
|
||||
rail.style.transition = 'opacity 0.2s linear';
|
||||
rail.style.zIndex = '90';
|
||||
|
||||
// Set position
|
||||
var posCss = (o.position === 'right') ?
|
||||
{right: o.offsetX} :
|
||||
{left: o.offsetX};
|
||||
for (var pos in posCss) {
|
||||
rail.style[pos] = posCss[pos];
|
||||
bar.style[pos] = posCss[pos];
|
||||
}
|
||||
|
||||
// Wrap element
|
||||
me.parentNode.insertBefore(wrapper, me);
|
||||
wrapper.appendChild(me);
|
||||
wrapper.appendChild(bar);
|
||||
wrapper.appendChild(rail);
|
||||
|
||||
var getBarHeight = function () {
|
||||
const state = privateState.get(self);
|
||||
const offsetHeight = parseInt(o.offsetY) || 0;
|
||||
const availableHeight = wrapper.offsetHeight - (2 * offsetHeight);
|
||||
|
||||
// Calculate scrollbar height and make sure it is not too small
|
||||
state.barHeight = Math.max(
|
||||
(availableHeight / me.scrollHeight) * availableHeight,
|
||||
state.minBarHeight
|
||||
);
|
||||
bar.style.height = state.barHeight + 'px';
|
||||
|
||||
// Hide scrollbar if content is not long enough
|
||||
var display = state.barHeight >= availableHeight ? 'none' : 'block';
|
||||
bar.style.display = display;
|
||||
};
|
||||
|
||||
getBarHeight();
|
||||
|
||||
var scrollContent = function (y, isWheel, isJump) {
|
||||
const state = privateState.get(self);
|
||||
state.releaseScroll = false;
|
||||
var delta = y;
|
||||
const offsetHeight = parseInt(o.offsetY) || 0;
|
||||
const availableHeight = wrapper.offsetHeight - (2 * offsetHeight);
|
||||
const maxTop = availableHeight - bar.offsetHeight;
|
||||
|
||||
if (isWheel) {
|
||||
// Move bar with mouse wheel
|
||||
delta = parseInt(bar.style.top || offsetHeight) + y * parseInt(o.wheelStep) / 100 * bar.offsetHeight;
|
||||
|
||||
// Constrain within offsetY boundaries
|
||||
delta = Math.min(Math.max(delta, offsetHeight), maxTop + offsetHeight);
|
||||
delta = (y > 0) ? Math.ceil(delta) : Math.floor(delta);
|
||||
bar.style.top = delta + 'px';
|
||||
}
|
||||
|
||||
// Calculate actual scroll amount
|
||||
state.percentScroll = (parseInt(bar.style.top || 0) - offsetHeight) / (availableHeight - bar.offsetHeight);
|
||||
delta = state.percentScroll * (me.scrollHeight - wrapper.offsetHeight);
|
||||
|
||||
if (isJump) {
|
||||
delta = y;
|
||||
var offsetTop = (delta / me.scrollHeight * availableHeight) + offsetHeight;
|
||||
offsetTop = Math.min(Math.max(offsetTop, offsetHeight), maxTop + offsetHeight);
|
||||
bar.style.top = offsetTop + 'px';
|
||||
}
|
||||
|
||||
// Scroll content
|
||||
me.scrollTop = delta;
|
||||
|
||||
// Ensure bar is visible
|
||||
showBar();
|
||||
hideBar();
|
||||
};
|
||||
|
||||
var showBar = function () {
|
||||
const state = privateState.get(self);
|
||||
getBarHeight();
|
||||
clearTimeout(state.queueHide);
|
||||
|
||||
// Show bar if content is scrollable and we're either:
|
||||
// 1. Over the content panel
|
||||
// 2. Over the bar/rail
|
||||
// 3. Currently dragging
|
||||
// 4. Currently scrolling
|
||||
if (me.scrollHeight > wrapper.offsetHeight &&
|
||||
(state.isOverPanel || state.isOverBar || state.isOverRail || state.isDragg || state.isScrolling)) {
|
||||
bar.style.opacity = o.opacity;
|
||||
rail.style.opacity = o.railOpacity;
|
||||
|
||||
// If only hovering over panel (not interacting), hide after delay
|
||||
if (state.isOverPanel &&
|
||||
!state.isOverBar &&
|
||||
!state.isOverRail &&
|
||||
!state.isDragg &&
|
||||
!state.isScrolling) {
|
||||
clearTimeout(state.queueHide);
|
||||
state.queueHide = setTimeout(function () {
|
||||
hideBar();
|
||||
}, o.fadeOutSpeed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var hideBar = function () {
|
||||
const state = privateState.get(self);
|
||||
|
||||
if (!o.alwaysVisible) {
|
||||
clearTimeout(state.queueHide);
|
||||
|
||||
state.queueHide = setTimeout(function () {
|
||||
if (!state.isOverBar && !state.isOverRail &&
|
||||
!state.isDragg && !state.isScrolling && !o.disableFadeOut) {
|
||||
bar.style.opacity = 0;
|
||||
rail.style.opacity = 0;
|
||||
}
|
||||
}, o.fadeOutSpeed);
|
||||
}
|
||||
};
|
||||
|
||||
// Update event listeners
|
||||
if (window.addEventListener) {
|
||||
// Bind to the wrapper with capture phase
|
||||
wrapper.addEventListener('mousewheel', handleWheel, {
|
||||
passive: false,
|
||||
capture: true
|
||||
});
|
||||
wrapper.addEventListener('DOMMouseScroll', handleWheel, {
|
||||
passive: false,
|
||||
capture: true
|
||||
});
|
||||
wrapper.addEventListener('wheel', handleWheel, {
|
||||
passive: false,
|
||||
capture: true
|
||||
});
|
||||
|
||||
// Also bind to the content element itself
|
||||
me.addEventListener('mousewheel', handleWheel, {
|
||||
passive: false,
|
||||
capture: true
|
||||
});
|
||||
me.addEventListener('DOMMouseScroll', handleWheel, {
|
||||
passive: false,
|
||||
capture: true
|
||||
});
|
||||
me.addEventListener('wheel', handleWheel, {
|
||||
passive: false,
|
||||
capture: true
|
||||
});
|
||||
|
||||
wrapper.addEventListener('touchmove', handleTouchMove, {passive: false, capture: true});
|
||||
} else {
|
||||
// IE8 and below
|
||||
wrapper.attachEvent('onmousewheel', handleWheel);
|
||||
me.attachEvent('onmousewheel', handleWheel);
|
||||
wrapper.attachEvent('touchmove', handleTouchMove);
|
||||
}
|
||||
|
||||
// Set up initial height
|
||||
getBarHeight();
|
||||
|
||||
// Check start position
|
||||
if (o.start === 'bottom') {
|
||||
bar.style.top = (wrapper.offsetHeight - bar.offsetHeight) + 'px';
|
||||
scrollContent(0, true);
|
||||
} else if (o.start !== 'top') {
|
||||
scrollContent(document.querySelector(o.start).offsetTop, null, true);
|
||||
if (!o.alwaysVisible) {
|
||||
bar.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Attach events
|
||||
var onMouseDown = function (e) {
|
||||
const state = privateState.get(self);
|
||||
state.isDragg = true;
|
||||
showBar();
|
||||
// Add dragging class
|
||||
bar.classList.add('dragging');
|
||||
|
||||
const offsetY = parseInt(o.offsetY) || 0;
|
||||
var top = parseFloat(bar.style.top) || offsetY;
|
||||
var pageY = e.pageY;
|
||||
|
||||
var moveHandler = function (e) {
|
||||
var currTop = top + e.pageY - pageY;
|
||||
// Account for offsetY in both min and max boundaries
|
||||
currTop = Math.min(
|
||||
Math.max(currTop, offsetY),
|
||||
wrapper.offsetHeight - bar.offsetHeight - offsetY
|
||||
);
|
||||
bar.style.top = currTop + 'px';
|
||||
scrollContent(0, currTop, false);
|
||||
};
|
||||
|
||||
var upHandler = function () {
|
||||
state.isDragg = false;
|
||||
// Remove dragging class
|
||||
bar.classList.remove('dragging');
|
||||
if (!state.isOverBar) {
|
||||
hideBar();
|
||||
}
|
||||
document.removeEventListener('mousemove', moveHandler);
|
||||
document.removeEventListener('mouseup', upHandler);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', moveHandler);
|
||||
document.addEventListener('mouseup', upHandler);
|
||||
return false;
|
||||
};
|
||||
|
||||
bar.addEventListener('mouseenter', function () {
|
||||
const state = privateState.get(self);
|
||||
state.isOverBar = true;
|
||||
showBar();
|
||||
});
|
||||
|
||||
bar.addEventListener('mouseleave', function () {
|
||||
const state = privateState.get(self);
|
||||
state.isOverBar = false;
|
||||
if (!state.isDragg) {
|
||||
hideBar();
|
||||
}
|
||||
});
|
||||
|
||||
wrapper.addEventListener('mouseenter', function () {
|
||||
const state = privateState.get(self);
|
||||
state.isOverPanel = true;
|
||||
|
||||
// Only show scrollbar if content is scrollable
|
||||
if (me.scrollHeight > wrapper.offsetHeight) {
|
||||
showBar();
|
||||
}
|
||||
});
|
||||
|
||||
wrapper.addEventListener('mouseleave', function (e) {
|
||||
const state = privateState.get(self);
|
||||
// Check if we're not moving to the scrollbar or rail
|
||||
if (!e.relatedTarget ||
|
||||
(!e.relatedTarget.closest('.' + o.barClass) &&
|
||||
!e.relatedTarget.closest('.' + o.railClass))) {
|
||||
state.isOverPanel = false;
|
||||
hideBar();
|
||||
}
|
||||
});
|
||||
|
||||
// Support for mobile devices
|
||||
wrapper.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length) {
|
||||
const state = privateState.get(self);
|
||||
state.touchDif = e.touches[0].pageY;
|
||||
}
|
||||
});
|
||||
|
||||
// Add back the mousedown event listener after the mouseenter/mouseleave events
|
||||
bar.addEventListener('mousedown', onMouseDown);
|
||||
|
||||
// Add rail hover events
|
||||
rail.addEventListener('mouseenter', function () {
|
||||
const state = privateState.get(self);
|
||||
state.isOverRail = true;
|
||||
showBar();
|
||||
});
|
||||
|
||||
rail.addEventListener('mouseleave', function () {
|
||||
const state = privateState.get(self);
|
||||
state.isOverRail = false;
|
||||
if (!state.isDragg) {
|
||||
hideBar();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle rail clicks
|
||||
rail.addEventListener('mousedown', function (e) {
|
||||
const state = privateState.get(self);
|
||||
|
||||
// Calculate relative click position
|
||||
const railOffset = rail.getBoundingClientRect();
|
||||
const clickPos = e.clientY - railOffset.top;
|
||||
const offsetY = parseInt(o.offsetY) || 0;
|
||||
|
||||
// Calculate target position (accounting for bar height)
|
||||
const availableHeight = wrapper.offsetHeight - (2 * offsetY);
|
||||
const barHalfHeight = bar.offsetHeight / 2;
|
||||
let targetPos = Math.min(
|
||||
Math.max(clickPos - barHalfHeight, 0),
|
||||
availableHeight - bar.offsetHeight
|
||||
);
|
||||
|
||||
// Update bar position
|
||||
bar.style.top = (targetPos + offsetY) + 'px';
|
||||
|
||||
// Scroll content
|
||||
const scrollRatio = (targetPos) / (availableHeight - bar.offsetHeight);
|
||||
const scrollPos = scrollRatio * (me.scrollHeight - wrapper.offsetHeight);
|
||||
me.scrollTop = scrollPos;
|
||||
|
||||
showBar();
|
||||
});
|
||||
|
||||
// Replace the try-catch block with a single mousemove listener
|
||||
document.addEventListener('mousemove', function initCheck(e) {
|
||||
const elementAtPoint = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (elementAtPoint && (wrapper.contains(elementAtPoint) || wrapper === elementAtPoint)) {
|
||||
const state = privateState.get(self);
|
||||
state.isOverPanel = true;
|
||||
if (me.scrollHeight > wrapper.offsetHeight) {
|
||||
showBar();
|
||||
}
|
||||
}
|
||||
document.removeEventListener('mousemove', initCheck);
|
||||
}, {once: true});
|
||||
} catch (error) {
|
||||
console.error('smartSlimScroll initialization failed:', error);
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
destroy: function () {
|
||||
try {
|
||||
const state = privateState.get(this);
|
||||
if (state.cleanup) {
|
||||
state.cleanup();
|
||||
}
|
||||
// Remove DOM elements and restore original state
|
||||
this.element.style = '';
|
||||
this.element.parentNode.replaceChild(this.element, this.element.parentNode);
|
||||
} catch (error) {
|
||||
console.error('smartSlimScroll destruction failed:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Expose to window
|
||||
window.smartSlimScroll = smartSlimScroll;
|
||||
|
||||
})(window, document);
|
||||
Reference in New Issue
Block a user