UNPKG

readmore-lines

Version:

A lightweight JavaScript library for creating 'read more/read less' functionality with text truncation

273 lines (245 loc) 9.94 kB
/*! * ReadMoreLines.js v1.0.0 * A lightweight JavaScript library for creating 'read more/read less' functionality * (c) 2025 Konstantin Agafonov * Released under the MIT License */ /** * @fileoverview ReadMore.js - A utility library for creating "read more/read less" functionality * that truncates long text content and provides expandable sections. * * @author [Konstantin Agafonov] * @version 1.0.0 * @since 2025 */ // Global cache to track added CSS styles const CSS_CACHE = new Set(); // Global cache to store computed line heights for elements const LINE_HEIGHT_CACHE = new WeakMap(); /** * Utility function to add CSS styles to the document head with caching. * This function creates a new style element and appends it to the document head, * but only if the styles haven't been added before. * * @param {string} styleString - The CSS string to be added to the document * @param {string} cacheKey - Unique identifier for the CSS to prevent duplicates * @returns {void} */ function addStyle(styleString, cacheKey) { // Check if styles with this cache key already exist in our global cache if (CSS_CACHE.has(cacheKey)) { return; // Styles already added, skip } // Check if styles with this cache key already exist in the DOM const existingStyle = document.head.querySelector(`[data-readmore-lines-cache="${cacheKey}"]`); if (existingStyle) { CSS_CACHE.add(cacheKey); // Add to cache for future reference return; // Styles already added, skip } const style = document.createElement('style'); style.textContent = styleString; style.setAttribute('data-readmore-lines-cache', cacheKey); document.head.append(style); // Add to cache to prevent future duplicates CSS_CACHE.add(cacheKey); } /** * Gets the computed line height of an element in pixels with caching. * This function caches the computed line height to avoid repeated calculations. * * @param {HTMLElement} element - The DOM element to get the line height from * @returns {number} The line height in pixels, or NaN if unable to determine */ function getLineHeight(element) { // Check if line height is already cached for this element if (LINE_HEIGHT_CACHE.has(element)) { return LINE_HEIGHT_CACHE.get(element); } // Calculate line height and cache it const lineHeight = parseInt(window.getComputedStyle(element).lineHeight, 10); LINE_HEIGHT_CACHE.set(element, lineHeight); return lineHeight; } /** * Calculates the number of lines of text in an element. * This is used to determine if the content exceeds the specified line limit. * * @param {HTMLElement} element - The DOM element to count lines in * @returns {number} The number of lines, or NaN if unable to calculate */ function countLines(element) { // Validate input if (!element || !(element instanceof HTMLElement)) { return NaN; } const divHeight = element.offsetHeight; const lineHeight = getLineHeight(element); // Return NaN if either height or line height cannot be determined if (isNaN(lineHeight) || isNaN(divHeight) || lineHeight <= 0 || divHeight <= 0) { return NaN; } return Math.round(divHeight / lineHeight); } /** * Main function that implements the "read more/read less" functionality. * This function truncates long text content and adds a toggle button to expand/collapse the content. * * Performance optimizations: * - CSS styles are cached to prevent duplicate additions * - Line height calculations are cached per element * - Uses WeakMap for automatic garbage collection of cached values * * Accessibility features: * - Uses semantic button element instead of anchor * - Includes aria-expanded attribute for screen readers * - Supports keyboard navigation (Enter and Space keys) * - Automatically assigns unique IDs for aria-controls * * @param {Object} options - Configuration object for the readmore functionality * @param {HTMLElement} options.targetElement - The DOM element to apply readmore functionality to (required, must have a parent node) * @param {string} [options.readMoreLabel='Read more...'] - Text for the "read more" button (must be a string if provided) * @param {string} [options.readLessLabel='Read less'] - Text for the "read less" button (must be a string if provided) * @param {string} [options.targetClass='read-more-target'] - CSS class to apply to the target element (must be a string if provided) * @param {string} [options.linkClass='read-more-link'] - CSS class to apply to the toggle button (must be a string if provided) * @param {number} [options.linesLimit=8] - Maximum number of lines to show before truncating (must be a positive integer if provided) * @returns {void} Returns early with error logging if validation fails * @throws {Error} Logs errors to console for invalid inputs * * @example * // Basic usage * readmore({ * targetElement: document.getElementById('my-text'), * linesLimit: 5 * }); * * @example * // Custom configuration * readmore({ * targetElement: document.querySelector('.content'), * readMoreLabel: 'Show more...', * readLessLabel: 'Show less', * linesLimit: 3 * }); */ function readmore({ targetElement, readMoreLabel, readLessLabel, targetClass, linkClass, linesLimit }) { // Input validation for targetElement if (!targetElement) { console.error('ReadMore: targetElement is required and cannot be null or undefined'); return; } if (!(targetElement instanceof HTMLElement)) { console.error('ReadMore: targetElement must be a valid HTMLElement'); return; } if (!targetElement.parentNode) { console.error('ReadMore: targetElement must have a parent node to insert the toggle link'); return; } // Validate linesLimit if provided if (linesLimit !== undefined && (typeof linesLimit !== 'number' || linesLimit < 1 || !Number.isInteger(linesLimit))) { console.error('ReadMore: linesLimit must be a positive integer'); return; } // Validate string parameters if (readMoreLabel !== undefined && typeof readMoreLabel !== 'string') { console.error('ReadMore: readMoreLabel must be a string'); return; } if (readLessLabel !== undefined && typeof readLessLabel !== 'string') { console.error('ReadMore: readLessLabel must be a string'); return; } if (targetClass !== undefined && typeof targetClass !== 'string') { console.error('ReadMore: targetClass must be a string'); return; } if (linkClass !== undefined && typeof linkClass !== 'string') { console.error('ReadMore: linkClass must be a string'); return; } // Set default values for configuration options const LINES_LIMIT = linesLimit || 8; const READ_MORE_LINK_CLASS = linkClass || 'read-more-link'; const READ_MORE_TARGET_CLASS = targetClass || 'read-more-target'; const READ_MORE_LABEL = readMoreLabel || 'Read more...'; const READ_LESS_LABEL = readLessLabel || 'Read less'; // Early return if content doesn't exceed the line limit if (countLines(targetElement) < LINES_LIMIT) { return; } // Prevent duplicate initialization if (targetElement.classList.contains(READ_MORE_TARGET_CLASS) || targetElement.dataset.readmoreLinesEnabled === '1') { return; } // Add CSS styles for text truncation using webkit-line-clamp with caching // Create a unique cache key based on target class and line limit const cssCacheKey = `readmore-lines-styles-${READ_MORE_TARGET_CLASS}-${LINES_LIMIT}`; addStyle(` .${READ_MORE_TARGET_CLASS} { display: -webkit-box; overflow : hidden; text-overflow: ellipsis; -webkit-line-clamp: ${LINES_LIMIT}; -webkit-box-orient: vertical; } `, cssCacheKey); // Create the toggle link element with accessibility attributes const readMoreLink = document.createElement('button'); readMoreLink.type = 'button'; readMoreLink.innerText = READ_MORE_LABEL; readMoreLink.classList.add(READ_MORE_LINK_CLASS); // Add accessibility attributes readMoreLink.setAttribute('aria-expanded', 'false'); readMoreLink.setAttribute('aria-controls', targetElement.id || `readmore-content-${Date.now()}`); readMoreLink.setAttribute('role', 'button'); // Ensure target element has an ID for aria-controls if (!targetElement.id) { targetElement.id = `readmore-content-${Date.now()}`; } try { // Insert the link after the target element with error handling targetElement.parentNode.insertBefore(readMoreLink, targetElement.nextSibling); // Apply the truncation class to the target element targetElement.classList.add(READ_MORE_TARGET_CLASS); } catch (error) { console.error('ReadMore: Failed to insert toggle link', error); } // Toggle functionality const toggleContent = () => { // Toggle the truncation class targetElement.classList.toggle(READ_MORE_TARGET_CLASS); // Update aria-expanded attribute const isExpanded = !targetElement.classList.contains(READ_MORE_TARGET_CLASS); readMoreLink.setAttribute('aria-expanded', isExpanded.toString()); // Update link text based on current state if (targetElement.classList.contains(READ_MORE_TARGET_CLASS)) { readMoreLink.innerText = READ_MORE_LABEL; } else { readMoreLink.innerText = READ_LESS_LABEL; } }; // Add click event listener for toggle functionality readMoreLink.addEventListener('click', event => { event.preventDefault(); toggleContent(); }); // Add keyboard event listener for accessibility readMoreLink.addEventListener('keydown', event => { // Handle Enter and Space keys if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleContent(); } }); // Mark element as having readmore functionality enabled targetElement.dataset.readmoreLinesEnabled = '1'; } export { readmore as default }; //# sourceMappingURL=readmore.esm.js.map