Skip to content

VerticalAppBar Component Code ​

Dependencies

This component requires:

  • Vue 3 with Composition API

Full Component Code ​

vue
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue';

const props = defineProps({
  content: {
    type: String,
    default: '',
  },
  position: {
    type: String,
    default: 'top',
    validator: (value) =>
      ['top', 'bottom', 'left', 'right', 'top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(value),
  },
  trigger: {
    type: String,
    default: 'hover',
    validator: (value) => ['hover', 'click', 'focus'].includes(value),
  },
  showArrow: {
    type: Boolean,
    default: true,
  },
  offset: {
    type: Number,
    default: 8,
  },
  delay: {
    type: Number,
    default: 300,
  },
  hideDelay: {
    type: Number,
    default: 100,
  },
  customClass: {
    type: String,
    default: '',
  },
  disabled: {
    type: Boolean,
    default: false,
  },
});

const emit = defineEmits(['show', 'hide', 'toggle']);

// Refs
const triggerRef = ref(null);
const tooltipRef = ref(null);
const triggerEl = ref(null);

// Local state
const isVisible = ref(false);
const showTimeout = ref(null);
const hideTimeout = ref(null);
const tooltipPosition = ref({ top: '0px', left: '0px' });
const actualPosition = ref(props.position);
const arrowPosition = ref({});

// Collision detection helpers
const getViewportBounds = () => {
  return {
    width: window.innerWidth,
    height: window.innerHeight,
    scrollTop: window.pageYOffset || document.documentElement.scrollTop,
    scrollLeft: window.pageXOffset || document.documentElement.scrollLeft,
  };
};

const detectCollisions = (position, tooltipRect) => {
  const viewport = getViewportBounds();
  const buffer = 16; // 1rem buffer

  return {
    top: position.top < viewport.scrollTop + buffer,
    bottom: position.top + tooltipRect.height > viewport.scrollTop + viewport.height - buffer,
    left: position.left < viewport.scrollLeft + buffer,
    right: position.left + tooltipRect.width > viewport.scrollLeft + viewport.width - buffer,
  };
};

const getFlippedPosition = (position, collisions) => {
  // Flip vertically
  if ((position === 'top' || position.includes('top')) && collisions.top && !collisions.bottom) {
    return position.replace('top', 'bottom');
  }
  if ((position === 'bottom' || position.includes('bottom')) && collisions.bottom && !collisions.top) {
    return position.replace('bottom', 'top');
  }

  // Flip horizontally
  if (position === 'left' && collisions.left && !collisions.right) {
    return 'right';
  }
  if (position === 'right' && collisions.right && !collisions.left) {
    return 'left';
  }

  return position;
};

const calculatePosition = () => {
  if (!triggerEl.value || !tooltipRef.value) {
    return { top: '0px', left: '0px' };
  }

  const triggerRect = triggerEl.value.getBoundingClientRect();
  const tooltipRect = tooltipRef.value.getBoundingClientRect();
  const viewport = getViewportBounds();

  // Calculate initial position
  let position = calculateInitialPosition(triggerRect, tooltipRect, props.position, viewport);

  // Detect collisions
  const collisions = detectCollisions(position, tooltipRect);

  // Get flipped position if needed
  const flippedPosition = getFlippedPosition(props.position, collisions);
  actualPosition.value = flippedPosition;

  // Recalculate with flipped position if it changed
  if (flippedPosition !== props.position) {
    position = calculateInitialPosition(triggerRect, tooltipRect, flippedPosition, viewport);
  }

  // Final adjustments to keep within viewport
  position = adjustForViewportBounds(position, tooltipRect, viewport);

  return {
    top: `${position.top}px`,
    left: `${position.left}px`,
  };
};

const calculateInitialPosition = (triggerRect, tooltipRect, position, viewport) => {
  let top, left;
  const offset = props.offset;

  switch (position) {
    case 'top':
      top = triggerRect.top + viewport.scrollTop - tooltipRect.height - offset;
      left = triggerRect.left + viewport.scrollLeft + (triggerRect.width - tooltipRect.width) / 2;
      break;
    case 'bottom':
      top = triggerRect.bottom + viewport.scrollTop + offset;
      left = triggerRect.left + viewport.scrollLeft + (triggerRect.width - tooltipRect.width) / 2;
      break;
    case 'left':
      top = triggerRect.top + viewport.scrollTop + (triggerRect.height - tooltipRect.height) / 2;
      left = triggerRect.left + viewport.scrollLeft - tooltipRect.width - offset;
      break;
    case 'right':
      top = triggerRect.top + viewport.scrollTop + (triggerRect.height - tooltipRect.height) / 2;
      left = triggerRect.right + viewport.scrollLeft + offset;
      break;
    case 'top-left':
      top = triggerRect.top + viewport.scrollTop - tooltipRect.height - offset;
      left = triggerRect.left + viewport.scrollLeft;
      break;
    case 'top-right':
      top = triggerRect.top + viewport.scrollTop - tooltipRect.height - offset;
      left = triggerRect.right + viewport.scrollLeft - tooltipRect.width;
      break;
    case 'bottom-left':
      top = triggerRect.bottom + viewport.scrollTop + offset;
      left = triggerRect.left + viewport.scrollLeft;
      break;
    case 'bottom-right':
      top = triggerRect.bottom + viewport.scrollTop + offset;
      left = triggerRect.right + viewport.scrollLeft - tooltipRect.width;
      break;
  }

  return { top, left };
};

const adjustForViewportBounds = (position, tooltipRect, viewport) => {
  let { top, left } = position;
  const buffer = 16; // 1rem buffer

  // Adjust horizontal position
  if (left < viewport.scrollLeft + buffer) {
    left = viewport.scrollLeft + buffer;
  } else if (left + tooltipRect.width > viewport.scrollLeft + viewport.width - buffer) {
    left = viewport.scrollLeft + viewport.width - tooltipRect.width - buffer;
  }

  // Adjust vertical position
  if (top < viewport.scrollTop + buffer) {
    top = viewport.scrollTop + buffer;
  } else if (top + tooltipRect.height > viewport.scrollTop + viewport.height - buffer) {
    top = viewport.scrollTop + viewport.height - tooltipRect.height - buffer;
  }

  return { top, left };
};

const calculateArrowPosition = (triggerRect, tooltipRect, actualPos, viewport) => {
  const arrowOffset = 12; // Distance from tooltip edge
  let arrowStyle = {};

  // Calculate where the arrow should point relative to trigger center
  const triggerCenterX = triggerRect.left + triggerRect.width / 2;
  const triggerCenterY = triggerRect.top + triggerRect.height / 2;
  const tooltipLeft = parseFloat(tooltipPosition.value.left.replace('px', ''));
  const tooltipTop = parseFloat(tooltipPosition.value.top.replace('px', ''));

  if (actualPos.includes('top') || actualPos.includes('bottom')) {
    // For top/bottom positions, arrow should point to trigger center horizontally
    const arrowLeft = triggerCenterX - tooltipLeft;
    const maxArrowLeft = tooltipRect.width - arrowOffset;
    const minArrowLeft = arrowOffset;

    arrowStyle.left = `${Math.max(minArrowLeft, Math.min(maxArrowLeft, arrowLeft))}px`;
    arrowStyle.transform = 'translateX(-50%)';
  } else if (actualPos.includes('left') || actualPos.includes('right')) {
    // For left/right positions, arrow should point to trigger center vertically
    const arrowTop = triggerCenterY - tooltipTop;
    const maxArrowTop = tooltipRect.height - arrowOffset;
    const minArrowTop = arrowOffset;

    arrowStyle.top = `${Math.max(minArrowTop, Math.min(maxArrowTop, arrowTop))}px`;
    arrowStyle.transform = 'translateY(-50%)';
  }

  return arrowStyle;
};

const updatePosition = () => {
  if (tooltipRef.value && isVisible.value && triggerEl.value) {
    const newPosition = calculatePosition();
    tooltipPosition.value = newPosition;

    // Update arrow position after tooltip position is set
    nextTick(() => {
      if (tooltipRef.value && triggerEl.value) {
        const triggerRect = triggerEl.value.getBoundingClientRect();
        const tooltipRect = tooltipRef.value.getBoundingClientRect();
        const viewport = getViewportBounds();
        arrowPosition.value = calculateArrowPosition(triggerRect, tooltipRect, actualPosition.value, viewport);
      }
    });
  }
};

// Methods
const show = () => {
  if (props.disabled) return;

  clearTimeout(hideTimeout.value);

  if (props.delay > 0) {
    showTimeout.value = setTimeout(() => {
      isVisible.value = true;
      emit('show');
      nextTick(() => {
        updatePosition();
      });
    }, props.delay);
  } else {
    isVisible.value = true;
    emit('show');
    nextTick(() => {
      updatePosition();
    });
  }
};

const hide = () => {
  clearTimeout(showTimeout.value);

  if (props.hideDelay > 0) {
    hideTimeout.value = setTimeout(() => {
      isVisible.value = false;
      emit('hide');
    }, props.hideDelay);
  } else {
    isVisible.value = false;
    emit('hide');
  }
};

const toggle = () => {
  if (isVisible.value) {
    hide();
  } else {
    show();
  }
  emit('toggle', isVisible.value);
};

// Event handlers
const handleMouseEnter = () => {
  if (props.trigger === 'hover') {
    show();
  }
};

const handleMouseLeave = () => {
  if (props.trigger === 'hover') {
    hide();
  }
};

const handleTriggerClick = (event) => {
  event.preventDefault();
  event.stopPropagation();

  if (props.trigger === 'click') {
    toggle();
  }
};

const handleFocus = (event) => {
  if (props.trigger === 'focus') {
    // Check if the focused element is an input, textarea, select, or has tabindex
    const focusableElements = ['input', 'textarea', 'select', 'button'];
    const target = event.target;

    if (
      focusableElements.includes(target.tagName.toLowerCase()) ||
      target.hasAttribute('tabindex') ||
      target.contentEditable === 'true'
    ) {
      show();
    }
  }
};

const handleBlur = (event) => {
  if (props.trigger === 'focus') {
    hide();
  }
};

// Computed tooltip positioning
const tooltipStyle = computed(() => {
  return {
    position: 'absolute',
    top: tooltipPosition.value.top,
    left: tooltipPosition.value.left,
    zIndex: 1000,
    visibility: isVisible.value ? 'visible' : 'hidden',
  };
});

// Event handlers for resize and scroll
const handleResize = () => {
  if (isVisible.value) {
    updatePosition();
  }
};

const handleScroll = () => {
  if (isVisible.value) {
    updatePosition();
  }
};

// Cleanup on unmount
onUnmounted(() => {
  clearTimeout(showTimeout.value);
  clearTimeout(hideTimeout.value);
});

// Handle click outside for click trigger
const handleClickOutside = (event) => {
  if (props.trigger === 'click' && isVisible.value) {
    if (
      triggerRef.value &&
      !triggerRef.value.contains(event.target) &&
      tooltipRef.value &&
      !tooltipRef.value.contains(event.target)
    ) {
      hide();
    }
  }
};

onMounted(() => {
  if (props.trigger === 'click') {
    document.addEventListener('click', handleClickOutside);
  }
  window.addEventListener('resize', handleResize);
  window.addEventListener('scroll', handleScroll, true);
});

onUnmounted(() => {
  if (props.trigger === 'click') {
    document.removeEventListener('click', handleClickOutside);
  }
  window.removeEventListener('resize', handleResize);
  window.removeEventListener('scroll', handleScroll, true);
  clearTimeout(showTimeout.value);
  clearTimeout(hideTimeout.value);
});

// Expose methods for programmatic control
defineExpose({
  show,
  hide,
  toggle,
  isVisible: computed(() => isVisible.value),
});
</script>

<template>
  <div class="tooltip-wrapper" ref="triggerRef">
    <div
      ref="triggerEl"
      @click="handleTriggerClick"
      @mouseenter="props.trigger === 'hover' ? handleMouseEnter() : null"
      @mouseleave="props.trigger === 'hover' ? handleMouseLeave() : null"
      @focusin="props.trigger === 'focus' ? handleFocus($event) : null"
      @focusout="props.trigger === 'focus' ? handleBlur($event) : null"
    >
      <slot></slot>
    </div>

    <!-- Tooltip using Teleport for better positioning -->
    <Teleport to="body">
      <Transition name="tooltip-fade">
        <div
          v-if="isVisible"
          ref="tooltipRef"
          :class="['tooltip', `tooltip-${actualPosition}`, { 'tooltip-no-arrow': !showArrow }, customClass]"
          :style="tooltipStyle"
        >
          <div class="tooltip-content">
            <slot name="tooltip">
              {{ content }}
            </slot>
          </div>

          <!-- Arrow -->
          <div v-if="showArrow" class="tooltip-arrow" :style="arrowPosition"></div>
        </div>
      </Transition>
    </Teleport>
  </div>
</template>

<style scoped lang="scss">
.tooltip-wrapper {
  position: relative;
  display: inline-block;
}

.tooltip {
  background: #1f2937;
  color: white;
  border-radius: 6px;
  padding: 8px 12px;
  font-size: 14px;
  line-height: 1.4;
  white-space: nowrap;
  box-shadow:
    0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
  pointer-events: none;

  .tooltip-content {
    position: relative;
    z-index: 1;
  }

  .tooltip-arrow {
    position: absolute;
    width: 0;
    height: 0;
    border-style: solid;
  }
}

// Position-specific arrow styles with dynamic positioning
.tooltip-top .tooltip-arrow {
  top: 100%;
  border-width: 6px 6px 0 6px;
  border-color: #1f2937 transparent transparent transparent;
}

.tooltip-bottom .tooltip-arrow {
  bottom: 100%;
  border-width: 0 6px 6px 6px;
  border-color: transparent transparent #1f2937 transparent;
}

.tooltip-left .tooltip-arrow {
  left: 100%;
  border-width: 6px 0 6px 6px;
  border-color: transparent transparent transparent #1f2937;
}

.tooltip-right .tooltip-arrow {
  right: 100%;
  border-width: 6px 6px 6px 0;
  border-color: transparent #1f2937 transparent transparent;
}

.tooltip-top-left .tooltip-arrow,
.tooltip-top-right .tooltip-arrow {
  top: 100%;
  border-width: 6px 6px 0 6px;
  border-color: #1f2937 transparent transparent transparent;
}

.tooltip-bottom-left .tooltip-arrow,
.tooltip-bottom-right .tooltip-arrow {
  bottom: 100%;
  border-width: 0 6px 6px 6px;
  border-color: transparent transparent #1f2937 transparent;
}

// No arrow variant
.tooltip-no-arrow .tooltip-arrow {
  display: none;
}

// Smooth transition animations
.tooltip-fade-enter-active {
  transition:
    opacity 0.2s ease-out,
    transform 0.2s ease-out;
}

.tooltip-fade-leave-active {
  transition:
    opacity 0.15s ease-in,
    transform 0.15s ease-in;
}

.tooltip-fade-enter-from {
  opacity: 0;
  transform: translateY(-4px);
}

.tooltip-fade-leave-to {
  opacity: 0;
  transform: translateY(-4px);
}

// Theme variants with proper arrow colors
.tooltip.tooltip-light {
  background: white;
  color: #1f2937;
  border: 1px solid #e5e7eb;

  &.tooltip-top .tooltip-arrow {
    border-top-color: white;
  }
  &.tooltip-bottom .tooltip-arrow {
    border-bottom-color: white;
  }
  &.tooltip-left .tooltip-arrow {
    border-left-color: white;
  }
  &.tooltip-right .tooltip-arrow {
    border-right-color: white;
  }
}

// Success, error, warning variants with proper arrow colors
.tooltip.tooltip-success {
  background: #10b981;

  &.tooltip-top .tooltip-arrow,
  &.tooltip-top-left .tooltip-arrow,
  &.tooltip-top-right .tooltip-arrow {
    border-top-color: #10b981;
  }
  &.tooltip-bottom .tooltip-arrow,
  &.tooltip-bottom-left .tooltip-arrow,
  &.tooltip-bottom-right .tooltip-arrow {
    border-bottom-color: #10b981;
  }
  &.tooltip-left .tooltip-arrow {
    border-left-color: #10b981;
  }
  &.tooltip-right .tooltip-arrow {
    border-right-color: #10b981;
  }
}

.tooltip.tooltip-error {
  background: #ef4444;

  &.tooltip-top .tooltip-arrow,
  &.tooltip-top-left .tooltip-arrow,
  &.tooltip-top-right .tooltip-arrow {
    border-top-color: #ef4444;
  }
  &.tooltip-bottom .tooltip-arrow,
  &.tooltip-bottom-left .tooltip-arrow,
  &.tooltip-bottom-right .tooltip-arrow {
    border-bottom-color: #ef4444;
  }
  &.tooltip-left .tooltip-arrow {
    border-left-color: #ef4444;
  }
  &.tooltip-right .tooltip-arrow {
    border-right-color: #ef4444;
  }
}

.tooltip.tooltip-warning {
  background: #f59e0b;

  &.tooltip-top .tooltip-arrow,
  &.tooltip-top-left .tooltip-arrow,
  &.tooltip-top-right .tooltip-arrow {
    border-top-color: #f59e0b;
  }
  &.tooltip-bottom .tooltip-arrow,
  &.tooltip-bottom-left .tooltip-arrow,
  &.tooltip-bottom-right .tooltip-arrow {
    border-bottom-color: #f59e0b;
  }
  &.tooltip-left .tooltip-arrow {
    border-left-color: #f59e0b;
  }
  &.tooltip-right .tooltip-arrow {
    border-right-color: #f59e0b;
  }
}
</style>