readmore-lines
Version:
A lightweight JavaScript library for creating 'read more/read less' functionality with text truncation
543 lines (468 loc) • 19 kB
JavaScript
/**
* @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();
// Instance management - track active readmore instances
const READMORE_INSTANCES = new WeakMap();
/**
* Instance data structure for tracking readmore instances
*/
class ReadMoreInstance {
constructor(targetElement, button, config) {
this.targetElement = targetElement;
this.button = button;
this.config = config;
this.eventListeners = new Map();
this.isDestroyed = false;
}
addEventListener(type, listener) {
this.button.addEventListener(type, listener);
this.eventListeners.set(type, listener);
}
removeAllEventListeners() {
this.eventListeners.forEach((listener, type) => {
this.button.removeEventListener(type, listener);
});
this.eventListeners.clear();
}
destroy() {
if (this.isDestroyed) return;
// Remove event listeners
this.removeAllEventListeners();
// Remove button from DOM
if (this.button && this.button.parentNode) {
this.button.parentNode.removeChild(this.button);
}
// Remove classes and data attributes
this.targetElement.classList.remove(this.config.targetClass);
delete this.targetElement.dataset.readmoreLinesEnabled;
// Clear line height cache for this element
invalidateLineHeightCache(this.targetElement);
// Clear style cache for this element's configuration
invalidateStyleCache(this.targetElement);
// Mark as destroyed
this.isDestroyed = true;
}
}
/**
* Destroys a readmore instance and cleans up all associated resources.
*
* @param {HTMLElement} targetElement - The target element that had readmore functionality
* @returns {boolean} True if instance was found and destroyed, false otherwise
*/
function destroyReadMore(targetElement) {
if (!targetElement || !(targetElement instanceof HTMLElement)) {
console.error('ReadMore: destroyReadMore requires a valid HTMLElement');
return false;
}
const instance = READMORE_INSTANCES.get(targetElement);
if (!instance) {
console.warn('ReadMore: No readmore instance found for the given element');
return false;
}
// Destroy the instance (includes line height and style cache invalidation)
instance.destroy();
// Remove from instance tracking
READMORE_INSTANCES.delete(targetElement);
return true;
}
/**
* Checks if an element has an active readmore instance.
*
* @param {HTMLElement} targetElement - The element to check
* @returns {boolean} True if element has an active readmore instance
*/
function hasReadMoreInstance(targetElement) {
return READMORE_INSTANCES.has(targetElement);
}
/**
* Gets the readmore instance for an element.
*
* @param {HTMLElement} targetElement - The element to get instance for
* @returns {ReadMoreInstance|null} The instance or null if not found
*/
function getReadMoreInstance(targetElement) {
return READMORE_INSTANCES.get(targetElement) || null;
}
/**
* Clears the CSS cache and removes all readmore styles from the document.
* This function can be used to reset the library state.
*
* @returns {void}
*/
function clearReadMoreCache() {
// Remove all readmore style elements from the document
const readmoreStyles = document.head.querySelectorAll('[data-readmore-lines-cache]');
readmoreStyles.forEach(style => style.remove());
// Clear the CSS cache
CSS_CACHE.clear();
// Note: Line height cache uses WeakMap which is automatically garbage collected
// when elements are removed from the DOM, so no manual clearing is needed
}
/**
* Checks if CSS styles for a specific configuration are already cached.
*
* @param {string} cacheKey - The cache key to check
* @returns {boolean} True if styles are cached, false otherwise
*/
function isStyleCached(cacheKey) {
return CSS_CACHE.has(cacheKey);
}
/**
* Checks if line height is cached for a specific element.
*
* @param {HTMLElement} element - The element to check
* @returns {boolean} True if line height is cached, false otherwise
*/
function isLineHeightCached(element) {
return LINE_HEIGHT_CACHE.has(element);
}
/**
* Invalidates the line height cache for an element.
* This should be called when an element's styles change and line height needs recalculation.
*
* @param {HTMLElement} element - The element to invalidate cache for
* @returns {void}
*/
function invalidateLineHeightCache(element) {
if (element) {
LINE_HEIGHT_CACHE.delete(element);
}
}
/**
* Invalidates the style cache for a specific element's configuration.
* This removes the CSS styles associated with the element's readmore configuration.
*
* @param {HTMLElement} element - The element to invalidate style cache for
* @returns {void}
*/
function invalidateStyleCache(element) {
if (!element || !(element instanceof HTMLElement)) {
return;
}
const instance = READMORE_INSTANCES.get(element);
if (!instance) {
return;
}
const cacheKey = `readmore-lines-styles-${instance.config.targetClass}-${instance.config.linesLimit}`;
CSS_CACHE.delete(cacheKey);
const styleElement = document.head.querySelector(`[data-readmore-lines-cache="${cacheKey}"]`);
if (styleElement) {
styleElement.remove();
}
}
/**
* 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;
}
// Check if element already has a readmore instance
if (hasReadMoreInstance(targetElement)) {
console.warn('ReadMore: Element already has readmore functionality. Use destroyReadMore() first to reinitialize.');
return;
}
// Ensure a local CSS scope on the container element
const scopeElement = targetElement.parentElement;
if (scopeElement && !scopeElement.hasAttribute('data-readmore-lines-scope')) {
scopeElement.setAttribute('data-readmore-lines-scope', '');
}
// Add CSS styles (scoped + themeable via CSS custom properties)
// Create a unique cache key based on target class and line limit
const cssCacheKey = `readmore-lines-styles-${READ_MORE_TARGET_CLASS}-${LINES_LIMIT}`;
addStyle(`
/*
* Scoped theme defaults. Override these on any ancestor with
* [data-readmore-lines-scope] using custom properties.
*/
[data-readmore-lines-scope] {
--readmore-link-color: #0a84ff;
--readmore-link-hover-color: #0066cc;
--readmore-link-bg: transparent;
--readmore-link-hover-bg: rgba(10, 132, 255, 0.08);
--readmore-link-padding-y: 0.25rem;
--readmore-link-padding-x: 0.5rem;
--readmore-link-radius: 4px;
--readmore-link-font-weight: 600;
--readmore-focus-ring: 2px solid rgba(10, 132, 255, 0.35);
--readmore-transition: color .15s ease, background-color .15s ease;
}
/* Truncation styling (scoped to container) */
[data-readmore-lines-scope] .${READ_MORE_TARGET_CLASS} {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: ${LINES_LIMIT};
-webkit-box-orient: vertical;
}
/* Toggle button baseline styles (accessible, themeable) */
[data-readmore-lines-scope] .${READ_MORE_LINK_CLASS} {
appearance: none;
-webkit-appearance: none;
background: var(--readmore-link-bg);
color: var(--readmore-link-color);
border: none;
padding: var(--readmore-link-padding-y) var(--readmore-link-padding-x);
margin: 0.25rem 0 0 0;
font: inherit;
font-weight: var(--readmore-link-font-weight);
text-decoration: none;
cursor: pointer;
border-radius: var(--readmore-link-radius);
transition: var(--readmore-transition);
line-height: 1.25;
display: inline-block;
}
[data-readmore-lines-scope] .${READ_MORE_LINK_CLASS}:hover {
background: var(--readmore-link-hover-bg);
color: var(--readmore-link-hover-color);
}
[data-readmore-lines-scope] .${READ_MORE_LINK_CLASS}:focus-visible {
outline: var(--readmore-focus-ring);
outline-offset: 2px;
}
/* Respect reduced motion preferences */
(prefers-reduced-motion: reduce) {
[data-readmore-lines-scope] .${READ_MORE_LINK_CLASS} {
transition: none;
}
}
`, 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);
}
// Create instance configuration
const instanceConfig = {
targetClass: READ_MORE_TARGET_CLASS,
linkClass: READ_MORE_LINK_CLASS,
readMoreLabel: READ_MORE_LABEL,
readLessLabel: READ_LESS_LABEL,
linesLimit: LINES_LIMIT
};
// Create readmore instance
const instance = new ReadMoreInstance(targetElement, readMoreLink, instanceConfig);
// 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
const clickHandler = (event) => {
event.preventDefault();
toggleContent();
};
// Add keyboard event listener for accessibility
const keydownHandler = (event) => {
// Handle Enter and Space keys
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleContent();
}
};
// Register event listeners through instance management
instance.addEventListener('click', clickHandler);
instance.addEventListener('keydown', keydownHandler);
// Store instance for cleanup
READMORE_INSTANCES.set(targetElement, instance);
// Mark element as having readmore functionality enabled
targetElement.dataset.readmoreLinesEnabled = '1';
}
// Export all functions
export {
destroyReadMore,
hasReadMoreInstance,
getReadMoreInstance,
clearReadMoreCache,
isStyleCached,
isLineHeightCached,
invalidateLineHeightCache,
invalidateStyleCache
};
export default readmore;