LineClamp.svelte
1 <script lang="ts" context="module"> 2 // A single observer is shared for all LineClamp instances for better performance. 3 // Using an observer also means recalculations are batched so layout only has to be 4 // recalculated once regardless of the number of instances of this component. 5 const resizeObserver = 6 typeof window !== 'undefined' && window.ResizeObserver 7 ? new window.ResizeObserver((entries) => { 8 for (const entry of entries) { 9 const contentHeight = Math.ceil(entry.contentRect.height); 10 const scrollHeight = Math.ceil(entry.target.scrollHeight); 11 const borderBoxHeight = Math.ceil( 12 entry.borderBoxSize[0].blockSize, 13 ); 14 15 const style = getComputedStyle(entry.target); 16 17 const lineHeight = parseInt( 18 style.getPropertyValue('line-height'), 19 ); 20 const multiline = contentHeight > lineHeight; 21 const multilineCount = contentHeight / lineHeight; 22 const truncated = scrollHeight > borderBoxHeight; 23 24 const event = new CustomEvent<LineClampResizeDetail>( 25 'lineClampResize', 26 { 27 detail: { 28 multiline, 29 multilineCount, 30 truncated, 31 }, 32 }, 33 ); 34 entry.target.dispatchEvent(event); 35 } 36 }) 37 : null; 38 </script> 39 40 <script lang="ts"> 41 import { onMount, createEventDispatcher } from 'svelte'; 42 import { getRafQueue } from '@amp/web-app-components/src/utils/rafQueue'; 43 44 /* 45 * Number of lines to clamp the container contents. 46 */ 47 export let clamp: number = 1; 48 49 /** 50 * Whether the clamp container should be observed for multiline change events. 51 * 52 * Observed containers emit the `resize` event with event detail 53 * { multiline: boolean, truncated: boolean }. 54 * - multiline (boolean): whether the container is more than one line tall 55 * - truncated (boolean): whether the text is truncated 56 * 57 * This can be used for conditional styling of other clamp containers which 58 * may be allowed to expand if an adjacent container is only a single line. 59 */ 60 export let observe: boolean = false; 61 62 /* 63 * Whether to allow focus indicators to overflow the container. 64 * 65 * Line clamping requires `overflow: hidden` in order to hide truncated contents. 66 * However, this will also clip focus indicators of elements inside the clamped 67 * container. Setting this to `true` allows focus indicators to overflow the 68 * clamped container while still hiding truncated contents. 69 * 70 * The amount of overflow bleed defaults to the Sass variable `$focus-size`, but 71 * can be adjusted using the CSS property `--overflowBleedSize`. 72 */ 73 export let allowFocusOverflow: boolean = false; 74 75 /** 76 * Since slots are not able to be wrapped ( https://github.com/sveltejs/svelte/issues/5604) 77 * We use this prop to determine if the badge should be rendered. 78 */ 79 export let shouldRenderBadgeSlots: boolean = true; 80 81 let clampElement: HTMLElement; 82 83 let multiline: boolean = false; 84 let truncated: boolean = false; 85 86 if (observe && resizeObserver) { 87 const dispatch = createEventDispatcher(); 88 const rafQueue = getRafQueue(); 89 90 onMount(() => { 91 resizeObserver.observe(clampElement); 92 clampElement.addEventListener( 93 'lineClampResize', 94 (e: CustomEvent<LineClampResizeDetail>) => { 95 dispatch('resize', e.detail); 96 97 // Multiline/truncation state is used for badge positioning 98 if ($$slots.badge && shouldRenderBadgeSlots) { 99 rafQueue.add(() => { 100 multiline = e.detail.multiline; 101 truncated = e.detail.truncated; 102 }); 103 } 104 }, 105 ); 106 107 return () => { 108 resizeObserver.unobserve(clampElement); 109 }; 110 }); 111 } 112 </script> 113 114 <!-- svelte-ignore a11y-unknown-role --> 115 <div 116 class="multiline-clamp" 117 class:multiline-clamp--overflow={allowFocusOverflow} 118 class:multiline-clamp--multiline={multiline} 119 class:multiline-clamp--truncated={truncated} 120 class:multiline-clamp--with-badge={$$slots.badge && shouldRenderBadgeSlots} 121 style="--mc-lineClamp: var(--defaultClampOverride, {clamp});" 122 bind:this={clampElement} 123 role="text" 124 > 125 <!-- 126 NOTE: Any elements slotted here *must* have `display: inline`, 127 otherwise the clamping will not take effect! 128 129 NOTE: In order for a multiline clamp with a badge to wrap correctly, 130 there must be *no whitespace* between the text element and badge 131 element. Otherwise, the badge will not "stick" to the last word, and 132 can end up wrapping onto its own line. 133 --> 134 <span class="multiline-clamp__text"><slot /></span 135 >{#if $$slots.badge && shouldRenderBadgeSlots}<span 136 class="multiline-clamp__badge"><slot name="badge" /></span 137 >{/if} 138 </div> 139 140 <style lang="scss"> 141 @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; 142 @use 'ac-sasskit/core/helpers' as *; 143 @use 'amp/stylekit/core/mixins/overflow-bleed' as *; 144 @use 'amp/stylekit/core/mixins/line-clamp' as *; 145 146 // Line Clamp 147 // 148 // PUBLIC CSS PROPS 149 // 150 // *cssprop {Number} --overflowBleedSize 151 // *access public 152 // Size of overflow bleed used when component prop `allowFocusOverflow` 153 // is `true`. 154 // 155 // *cssprop {Number} --badgeSize 156 // *access public 157 // Size of badge placed in component's `badge` slot, used for positioning 158 // when the line clamp overflows to multiple lines. 159 // 160 // 161 // PRIVATE CSS PROPS 162 // 163 // *cssprop {Number} --mc-overflowBleedSize [var(--overflowBleedSize, 0)] 164 // *access private 165 // Size of overflow bleed. 166 // 167 // *cssprop {Number} --mc-badgeSize [var(--badgeSize, 8px)] 168 // *access private 169 // Size of badge placed in component's `badge` slot. 170 // 171 // *cssprop {Number} --mc-badgeSpacing [var(--mc-badgeSize) + var(--mc-overflowBleedSize)] 172 // *access private 173 // Positioning helper to ensure badge wraps with text and doesn't 174 // get truncated. 175 // 176 // *cssprop {Number} --mc-lineClamp [1] 177 // *access private 178 // Number of lines to clamp. 179 // 180 181 .multiline-clamp { 182 --mc-overflowBleedSize: var(--overflowBleedSize, 0); 183 --mc-badgeSize: var(--badgeSize, 8px); 184 --mc-badgeSpacing: var(--mc-badgeSize); 185 word-break: break-word; // Allow long words to be truncated 186 187 @include line-clamp(var(--mc-lineClamp, 1)); 188 } 189 190 .multiline-clamp--overflow { 191 --mc-overflowBleedSize: var(--overflowBleedSize, #{$focus-size}); 192 --mc-badgeSpacing: calc( 193 var(--mc-badgeSize) + var(--mc-overflowBleedSize) 194 ); 195 196 // Clip overflow contents when unfocused in order to prevent content 197 // that falls within the overflow padding box from being displayed. 198 clip-path: inset(var(--mc-overflowBleedSize)); 199 200 // If container scrolls due to focus, keep focused item visible 201 scroll-padding: var(--mc-overflowBleedSize); 202 203 @include overflow-bleed(var(--mc-overflowBleedSize)); 204 205 &:focus-within { 206 clip-path: none; 207 } 208 } 209 210 .multiline-clamp--with-badge { 211 &.multiline-clamp--truncated { 212 position: relative; 213 214 // Adjust padding at end of clamp container so badge doesn't overlap text 215 padding-inline-end: var(--mc-badgeSpacing); 216 z-index: var(--z-default); 217 218 .multiline-clamp__badge { 219 display: block; 220 position: absolute; 221 bottom: var(--mc-overflowBleedSize); 222 inset-inline-end: var(--mc-overflowBleedSize); 223 z-index: var(--z-default); 224 } 225 } 226 227 // These styles on the text and badge create the effect of "sticking" 228 // the badge to the last word, so the badge never wraps to a new line on 229 // its own. 230 .multiline-clamp__text { 231 padding-inline-end: var(--mc-badgeSpacing); 232 } 233 234 .multiline-clamp__badge:not(:empty) { 235 margin-inline-start: calc(-1 * var(--mc-badgeSpacing)); 236 } 237 } 238 </style>