fix: implement Blazor-native login form to properly update authentication state
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:
2026-07-03 13:03:53 +09:00
parent 041d3cae96
commit 40cffb3beb
326 changed files with 327714 additions and 47 deletions
@@ -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);