/ shared / components / src / components / LineClamp / LineClamp.svelte
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>