Created
December 16, 2025 06:35
-
-
Save twofingerrightclick/fdaa7844cb52e12d2e9ed2dbbb928f9c to your computer and use it in GitHub Desktop.
Middle Ellipsis Truncation Web Component
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Middle Ellipsis Web Component</title> | |
| <style> | |
| body { | |
| font-family: system-ui, -apple-system, sans-serif; | |
| padding: 2rem; | |
| max-width: 800px; | |
| margin: 0 auto; | |
| line-height: 1.5; | |
| } | |
| .container { | |
| border: 1px solid #ccc; | |
| padding: 1rem; | |
| margin-bottom: 2rem; | |
| resize: horizontal; | |
| overflow: auto; | |
| width: 300px; /* Initial width */ | |
| background: #f9f9f9; | |
| } | |
| h2 { | |
| margin-top: 0; | |
| } | |
| /* Demo specific styles */ | |
| .ellipsis-box { | |
| margin-bottom: 0.5rem; | |
| background: white; | |
| border: 1px solid #eee; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Middle Truncation Web Component</h1> | |
| <p>Resize the containers below to see the truncation in action.</p> | |
| <div class="container"> | |
| <h2>Script Files (Monospace)</h2> | |
| <middle-ellipsis class="ellipsis-box"> | |
| <code style="font-family: monospace" | |
| >The_Princess_Bride_Script_Final_Draft_v4_Revised_By_Goldman.pdf</code | |
| > | |
| </middle-ellipsis> | |
| <middle-ellipsis class="ellipsis-box"> | |
| <code style="font-family: monospace" | |
| >Battle_of_Wits_Scene_Vizzini_vs_Westley_☠️_Poison.docx</code | |
| > | |
| </middle-ellipsis> | |
| <middle-ellipsis class="ellipsis-box"> | |
| <code style="font-family: monospace" | |
| >Rodents_Of_Unusual_Size_Location_Scouting_Photos_Fire_Swamp.jpg</code | |
| > | |
| </middle-ellipsis> | |
| </div> | |
| <div | |
| class="container" | |
| style=" | |
| width: 400px; | |
| font-family: 'American Typewriter', 'Baskerville', Georgia, serif; | |
| " | |
| > | |
| <h2>Dialogue & Assets (Variable Fonts)</h2> | |
| <middle-ellipsis class="ellipsis-box"> | |
| <span style="font-weight: bold" | |
| >Inigo_Montoya_Revenge_Speech_Draft_1_Hello_My_Name_Is.txt</span | |
| > | |
| </middle-ellipsis> | |
| <div | |
| style=" | |
| font-family: 'Chalkboard SE', 'Comic Sans MS', sans-serif; | |
| margin-top: 10px; | |
| " | |
| > | |
| <middle-ellipsis class="ellipsis-box"> | |
| <span>Mawage_Is_Wot_Bwings_Us_Togedder_Today_Speech_📜.rtf</span> | |
| </middle-ellipsis> | |
| </div> | |
| <div | |
| style=" | |
| font-family: 'Menlo', 'Monaco', 'Courier New', monospace; | |
| font-weight: bold; | |
| margin-top: 10px; | |
| " | |
| > | |
| <middle-ellipsis class="ellipsis-box"> | |
| <span | |
| >Miracle_Max_Resurrection_Pill_Ingredients_List_🍫_Chocolate_Coating.xlsx</span | |
| > | |
| </middle-ellipsis> | |
| </div> | |
| </div> | |
| <script> | |
| /** | |
| * Singleton for measuring text width using an off-screen canvas. | |
| * Fast compared to DOM measurement. | |
| */ | |
| const TextMeasurer = (() => { | |
| const canvas = document.createElement("canvas"); | |
| const ctx = canvas.getContext("2d"); | |
| return { | |
| width(text, font) { | |
| if (ctx.font !== font) { | |
| ctx.font = font; | |
| } | |
| return ctx.measureText(text).width; | |
| }, | |
| }; | |
| })(); | |
| /** | |
| * Singleton for splitting text into graphemes (handling emojis correctly). | |
| */ | |
| const GraphemeSplitter = (() => { | |
| let segmenter; | |
| try { | |
| segmenter = new Intl.Segmenter({ granularity: "grapheme" }); | |
| } catch (e) {} | |
| return { | |
| split(text) { | |
| if (segmenter) { | |
| return Array.from(segmenter.segment(text), (s) => s.segment); | |
| } | |
| return Array.from(text); | |
| }, | |
| }; | |
| })(); | |
| class MiddleEllipsis extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: "open" }); | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { | |
| display: block; | |
| overflow: hidden; | |
| white-space: nowrap; | |
| width: 100%; | |
| } | |
| ::slotted(*) { | |
| white-space: nowrap; | |
| display: inline-block; | |
| vertical-align: bottom; | |
| } | |
| </style> | |
| <slot></slot> | |
| `; | |
| } | |
| connectedCallback() { | |
| // Observe resize of the component itself | |
| this.resizeObserver = new ResizeObserver((entries) => { | |
| for (const entry of entries) { | |
| this.render(entry.contentRect.width); | |
| } | |
| }); | |
| this.resizeObserver.observe(this); | |
| // Listen for slot changes (new content) | |
| this.shadowRoot | |
| .querySelector("slot") | |
| .addEventListener("slotchange", () => this.render()); | |
| } | |
| disconnectedCallback() { | |
| if (this.resizeObserver) { | |
| this.resizeObserver.disconnect(); | |
| } | |
| } | |
| /** | |
| * Main render logic. | |
| * @param {number} [width] - The available width. | |
| */ | |
| render(width) { | |
| const slot = this.shadowRoot.querySelector("slot"); | |
| const elements = slot.assignedElements(); | |
| if (elements.length === 0) return; | |
| // Operate on the first element assigned to the slot | |
| const target = elements[0]; | |
| // Store original text on the element itself to avoid data loss | |
| if (!target._originalText) { | |
| target._originalText = target.textContent.trim(); | |
| } | |
| const text = target._originalText; | |
| const containerWidth = | |
| width !== undefined ? width : this.getBoundingClientRect().width; | |
| if (containerWidth === 0) return; | |
| // Measure using the TARGET element's styles | |
| const style = window.getComputedStyle(target); | |
| const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; | |
| // Optimization: If the full text fits, just show it | |
| const fullWidth = TextMeasurer.width(text, font); | |
| if (fullWidth <= containerWidth) { | |
| target.textContent = text; | |
| target.title = ""; // Clear tooltip | |
| return; | |
| } | |
| // Otherwise, calculate the truncated string | |
| target.textContent = this.middleTruncate(text, containerWidth, font); | |
| target.title = text; // Show full text on hover | |
| } | |
| /** | |
| * 50/50 split middle truncation based on rendered width. | |
| */ | |
| middleTruncate(text, containerWidth, font) { | |
| const ellipsis = "…"; | |
| const ellipsisWidth = TextMeasurer.width(ellipsis, font); | |
| // Available space for text (total - ellipsis) | |
| const availableWidth = containerWidth - ellipsisWidth; | |
| // Target width for each side (50% of available space) | |
| const halfWidth = Math.max(0, availableWidth / 2); | |
| // Split into graphemes to handle emojis correctly | |
| const graphemes = GraphemeSplitter.split(text); | |
| // Binary search for the left part | |
| let lo = 0, | |
| hi = graphemes.length; | |
| let leftCount = 0; | |
| while (lo <= hi) { | |
| const mid = Math.floor((lo + hi) / 2); | |
| const substr = graphemes.slice(0, mid).join(""); | |
| if (TextMeasurer.width(substr, font) <= halfWidth) { | |
| leftCount = mid; | |
| lo = mid + 1; | |
| } else { | |
| hi = mid - 1; | |
| } | |
| } | |
| const left = graphemes.slice(0, leftCount).join(""); | |
| // Binary search for the right part | |
| lo = 0; | |
| hi = graphemes.length; | |
| let rightCount = 0; | |
| while (lo <= hi) { | |
| const mid = Math.floor((lo + hi) / 2); | |
| const substr = graphemes.slice(graphemes.length - mid).join(""); | |
| if (TextMeasurer.width(substr, font) <= halfWidth) { | |
| rightCount = mid; | |
| lo = mid + 1; | |
| } else { | |
| hi = mid - 1; | |
| } | |
| } | |
| const right = graphemes.slice(graphemes.length - rightCount).join(""); | |
| return `${left}${ellipsis}${right}`; | |
| } | |
| } | |
| customElements.define("middle-ellipsis", MiddleEllipsis); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment