lib/components/ActiveTextTruncate.vue

Summary

Maintainability
Test Coverage
<script lang="ts">
import get from 'lodash/get'
import uniqueId from 'lodash/uniqueId'
import ResizeObserver from 'resize-observer-polyfill'
import { defineComponent } from 'vue'

import { RequestAnimationFrameWrapper } from '../utils/animation'

type ActiveTextTruncateData = { textLivePosition: number; resizeObserverKey: string | null }

export default defineComponent({
  name: 'ActiveTextTruncate',
  props: {
    /**
     * Number of Pixel Per Millisecond for the text transition.
     */
    ppms: {
      type: Number,
      default: 0.025
    },
    /**
     * Maximum width of the fading mask.
     */
    fadingMaxWidth: {
      type: Number,
      default: 50,
      validator: (value: number) => value > 0
    },
    /**
     * Minimum width of the fading mask.
     */
    fadingMinWidth: {
      type: Number,
      default: 0.001,
      validator: (value: number) => value > 0
    },
    /**
     * Delay to start moving the text (in milliseconds).
     */
    delay: {
      type: Number,
      default: 1000
    },
    /**
     * Direction of the truncate
     */
    direction: {
      type: String,
      default: 'ltr',
      validator: (value: string) => ['ltr', 'rtl'].indexOf(value) > -1
    }
  },
  data(): ActiveTextTruncateData {
    return {
      textLivePosition: 0,
      // This will hold a key generated every time the component is resized.
      resizeObserverKey: null
    }
  },
  resizeObserver: null,
  computed: {
    wrapperElement(): Element | null {
      const selector = '.active-text-truncate__wrapper'
      return this.resizeObserverKey ? this.$el.querySelector(selector) : null
    },
    wrapperElementWidth(): number {
      return get(this, 'wrapperElement.offsetWidth', 0)
    },
    textElement(): Element | null {
      const selector = '.active-text-truncate__wrapper__text'
      return this.resizeObserverKey ? this.$el.querySelector(selector) : null
    },
    textElementWidth(): number {
      return get(this, 'textElement.offsetWidth', 0)
    },
    textOffsetTransitionDelay(): string {
      return `${this.delay}ms`
    },
    textOffsetTransitionDuration(): string {
      const offset = Math.abs(this.wrapperElementWidth - this.textElementWidth)
      const duration = offset / this.ppms
      return `${duration}ms`
    },
    textInitialOffset(): string {
      return '0'
    },
    textFinalOffset(): string {
      const offset = this.wrapperElementWidth - this.textElementWidth
      return `${offset}px`
    },
    textOffsetValues(): string[] {
      if (this.direction === 'ltr') {
        return [this.textInitialOffset, this.textFinalOffset]
      }
      return [this.textFinalOffset, this.textInitialOffset]
    },
    isFadingLeft(): boolean {
      return this.direction === 'rtl' && this.isFading
    },
    isFadingRight(): boolean {
      return this.direction === 'ltr' && this.isFading
    },
    isFading(): boolean {
      return this.wrapperElementWidth < this.textElementWidth
    },
    fadingLeftWidth(): string {
      const offset = this.textLivePosition
      const width = Math.min(Math.max(this.fadingMinWidth, Math.abs(offset)), this.fadingMaxWidth)
      return `${width}px`
    },
    fadingRightWidth(): string {
      const offset = parseInt(this.textFinalOffset) - this.textLivePosition
      const width = Math.min(Math.max(this.fadingMinWidth, Math.abs(offset)), this.fadingMaxWidth)
      return `${width}px`
    },
    textLivePositionRequestAnimationFrame(): RequestAnimationFrameWrapper {
      return new RequestAnimationFrameWrapper()
    }
  },
  async mounted() {
    this.$options.resizeObserver = new ResizeObserver(this.setup)
    // Bind the resize observer after the first rendering
    await this.$nextTick()
    this.$options.resizeObserver?.observe(this.$el)
  },
  beforeDestroy() {
    this.$options.resizeObserver?.unobserve(this.$el)
    this.$options.resizeObserver = null
  },
  methods: {
    setup() {
      this.resizeObserverKey = uniqueId()
      this.textLivePosition = parseInt(this.textOffsetValues[0])
      // Track transitions to update the text position in live using Request Animation Frame
      this.listenOnTextElement('transitionstart', this.startTrackingTextLivePosition)
      this.listenOnTextElement('transitionend', this.endTrackingTextLivePosition)
      this.listenOnTextElement('transitioncancel', this.resetTextLivePosition)
    },
    listenOnTextElement(name: string, func: () => void) {
      this.textElement?.removeEventListener(name, func)
      this.textElement?.addEventListener(name, func)
    },
    trackTextLivePosition(): void {
      if (!this.textElement) return
      const left = window.getComputedStyle(this.textElement, null).getPropertyValue('left')
      this.textLivePosition = parseInt(left)
    },
    startTrackingTextLivePosition(): void {
      this.textLivePositionRequestAnimationFrame.start(this.trackTextLivePosition)
      /**
       * Emitted when the animation on the text starts.
       * @event start
       */
      this.$emit('start')
    },
    endTrackingTextLivePosition(): void {
      this.textLivePositionRequestAnimationFrame.stop()
      /**
       * Emitted when the animation on the text reaches the end.
       * @event end
       */
      this.$emit('end')
    },
    resetTextLivePosition(): void {
      this.textLivePositionRequestAnimationFrame.stop()
      this.textLivePosition = parseInt(this.textOffsetValues[0])
      /**
       * Emitted when the animation on the text is cancelled.
       * @event cancel
       */
      this.$emit('cancel')
    }
  }
})
</script>

<template>
  <span
    class="active-text-truncate"
    :class="{
      'active-text-truncate--fading': isFading,
      [`active-text-truncate--${direction}`]: true
    }"
    :style="{
      '--fading-left-width': fadingLeftWidth,
      '--fading-right-width': fadingRightWidth,
      '--text-offset-transition-duration': textOffsetTransitionDuration,
      '--text-offset-transition-delay': textOffsetTransitionDelay,
      '--text-final-offset': textFinalOffset
    }"
    @mouseleave="resetTextLivePosition"
  >
    <span class="active-text-truncate__wrapper">
      <span class="active-text-truncate__wrapper__text">
        <slot />
      </span>
    </span>
  </span>
</template>

<style lang="scss" scoped>
@import '../styles/lib';
@import '../styles/mixins';

.active-text-truncate {
  --fading-left-width: 0;
  --fading-right-width: 0;
  --fading-left-gradient: linear-gradient(to left, black calc(100% - var(--fading-left-width)), transparent 100%);
  --fading-right-gradient: linear-gradient(to right, black calc(100% - var(--fading-right-width)), transparent 100%);
  --text-offset-transition-duration: 0ms;
  --text-offset-transition-delay: 0ms;
  --text-final-offset: 0;

  overflow: hidden;
  max-width: 100%;
  display: inline-block;
  position: relative;

  &:after {
    content: attr(data-text-live-position);
    position: absolute;
    left: 0;
    top: 0;
  }

  &__wrapper {
    width: 100%;
    display: inline-block;

    .active-text-truncate--fading & {
      mask: var(--fading-right-gradient), var(--fading-left-gradient);
      mask-composite: intersect;
    }

    .active-text-truncate--rtl.active-text-truncate--fading &:hover &__text,
    .active-text-truncate--ltr.active-text-truncate--fading &:hover &__text {
      transition: linear left var(--text-offset-transition-duration);
      transition-delay: var(--text-offset-transition-delay);
    }

    .active-text-truncate--ltr.active-text-truncate--fading &:hover &__text {
      left: var(--text-final-offset);
    }

    .active-text-truncate--rtl.active-text-truncate--fading &:hover &__text {
      left: 0;
    }

    &__text {
      white-space: nowrap;
      position: relative;
      left: 0;

      .active-text-truncate--rtl & {
        left: var(--text-final-offset);
      }
    }
  }
}
</style>