CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-vueuse--components

Renderless Vue.js components that expose VueUse composable functionality through declarative template-based interfaces

Pending
Overview
Eval results
Files

utilities-advanced.mddocs/

Utilities and Advanced

Components for utility functions, time management, virtual scrolling, pagination, and other advanced features.

Capabilities

UseTimestamp Component

Provides reactive current timestamp with customizable update intervals.

/**
 * Component that provides reactive timestamp
 * @example
 * <UseTimestamp v-slot="{ timestamp }">
 *   <div>Current time: {{ new Date(timestamp).toLocaleString() }}</div>
 * </UseTimestamp>
 */
interface UseTimestampProps {
  /** Offset to add to timestamp (ms) @default 0 */
  offset?: number;
  /** Update interval in milliseconds @default 1000 */
  interval?: number | 'requestAnimationFrame';
  /** Start immediately @default true */
  immediate?: boolean;
  /** Update on focus changes @default true */
  controls?: boolean;
}

/** Slot data exposed by UseTimestamp component */
interface UseTimestampReturn {
  /** Current timestamp in milliseconds */
  timestamp: Ref<number>;
  /** Pause timestamp updates */
  pause: () => void;
  /** Resume timestamp updates */
  resume: () => void;
}

Usage Examples:

<template>
  <!-- Basic timestamp -->
  <UseTimestamp v-slot="{ timestamp }">
    <div class="timestamp-display">
      <h3>Live Clock</h3>
      <div class="clock">
        <div class="time">{{ formatTime(timestamp) }}</div>
        <div class="date">{{ formatDate(timestamp) }}</div>
      </div>
    </div>
  </UseTimestamp>

  <!-- High frequency timestamp -->
  <UseTimestamp :interval="16" v-slot="{ timestamp }">
    <div class="high-freq-demo">
      <h3>High Frequency Timer (60 FPS)</h3>
      <div class="milliseconds">{{ timestamp % 1000 }}ms</div>
      <div class="animation-box" :style="{ transform: getAnimationTransform(timestamp) }">
        🚀
      </div>
    </div>
  </UseTimestamp>

  <!-- Controlled timestamp -->
  <UseTimestamp 
    :immediate="false"
    :interval="100"
    v-slot="{ timestamp, pause, resume }"
  >
    <div class="controlled-timestamp">
      <h3>Controlled Timer</h3>
      <div class="timer-display">{{ Math.floor((timestamp % 60000) / 1000) }}s</div>
      <div class="timer-controls">
        <button @click="resume">Start</button>
        <button @click="pause">Stop</button>
      </div>
    </div>
  </UseTimestamp>

  <!-- Timezone display -->
  <UseTimestamp v-slot="{ timestamp }">
    <div class="timezone-demo">
      <h3>World Clock</h3>
      <div class="timezone-grid">
        <div v-for="tz in timezones" :key="tz.zone" class="timezone-item">
          <div class="tz-name">{{ tz.name }}</div>
          <div class="tz-time">{{ formatTimeInTimezone(timestamp, tz.zone) }}</div>
        </div>
      </div>
    </div>
  </UseTimestamp>
</template>

<script setup>
import { ref } from 'vue';
import { UseTimestamp } from '@vueuse/components';

const timezones = [
  { name: 'New York', zone: 'America/New_York' },
  { name: 'London', zone: 'Europe/London' },
  { name: 'Tokyo', zone: 'Asia/Tokyo' },
  { name: 'Sydney', zone: 'Australia/Sydney' }
];

function formatTime(timestamp) {
  return new Date(timestamp).toLocaleTimeString();
}

function formatDate(timestamp) {
  return new Date(timestamp).toLocaleDateString();
}

function formatTimeInTimezone(timestamp, timezone) {
  return new Date(timestamp).toLocaleTimeString('en-US', { timeZone: timezone });
}

function getAnimationTransform(timestamp) {
  const angle = (timestamp / 10) % 360;
  return `rotate(${angle}deg) translateX(50px)`;
}
</script>

<style>
.timestamp-display, .high-freq-demo, .controlled-timestamp, .timezone-demo {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 20px;
  margin: 15px 0;
  text-align: center;
}

.clock {
  font-family: 'Courier New', monospace;
  background: #1a1a1a;
  color: #00ff00;
  padding: 20px;
  border-radius: 8px;
  margin: 15px 0;
}

.time {
  font-size: 2em;
  font-weight: bold;
}

.date {
  font-size: 1.2em;
  margin-top: 5px;
  opacity: 0.8;
}

.milliseconds {
  font-family: 'Courier New', monospace;
  font-size: 1.5em;
  font-weight: bold;
  color: #2196f3;
  margin: 15px 0;
}

.animation-box {
  display: inline-block;
  font-size: 2em;
  margin: 20px;
}

.timer-display {
  font-size: 3em;
  font-weight: bold;
  color: #4caf50;
  font-family: 'Courier New', monospace;
  margin: 15px 0;
}

.timer-controls {
  display: flex;
  justify-content: center;
  gap: 15px;
}

.timer-controls button {
  padding: 10px 20px;
  font-size: 16px;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  background: #2196f3;
  color: white;
}

.timezone-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 15px;
  margin-top: 20px;
}

.timezone-item {
  padding: 15px;
  border: 1px solid #ddd;
  border-radius: 6px;
  background: #f9f9f9;
}

.tz-name {
  font-weight: bold;
  margin-bottom: 8px;
  color: #666;
}

.tz-time {
  font-family: 'Courier New', monospace;
  font-size: 1.1em;
  color: #333;
}
</style>

UseTimeAgo Component

Formats timestamps as relative time strings with automatic updates.

/**
 * Component that formats dates as relative time
 * @example
 * <UseTimeAgo :time="date" v-slot="{ timeAgo }">
 *   <div>{{ timeAgo }}</div>
 * </UseTimeAgo>
 */
interface UseTimeAgoProps {
  /** The date/time to format */
  time: MaybeRefOrGetter<Date | number | string>;
  /** Update interval in milliseconds @default 30000 */
  updateInterval?: number;
  /** Maximum unit to display @default 'month' */
  max?: TimeAgoUnit;
  /** Full date format when max exceeded @default 'YYYY-MM-DD' */
  fullDateFormatter?: (date: Date) => string;
  /** Custom messages for different time units */
  messages?: TimeAgoMessages;
  /** Show just now instead of 0 seconds @default false */
  showSecond?: boolean;
  /** Rounding method @default 'round' */
  rounding?: 'floor' | 'ceil' | 'round';
}

/** Slot data exposed by UseTimeAgo component */
interface UseTimeAgoReturn {
  /** Formatted relative time string */
  timeAgo: Ref<string>;
}

type TimeAgoUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';

interface TimeAgoMessages {
  justNow?: string;
  past?: string | ((n: string) => string);
  future?: string | ((n: string) => string);
  month?: string | string[];
  year?: string | string[];
  day?: string | string[];
  week?: string | string[];
  hour?: string | string[];
  minute?: string | string[];
  second?: string | string[];
}

Usage Examples:

<template>
  <!-- Basic time ago -->
  <div class="time-ago-demo">
    <h3>Time Ago Examples</h3>
    <div class="time-examples">
      <div v-for="example in timeExamples" :key="example.label" class="time-example">
        <div class="example-label">{{ example.label }}:</div>
        <UseTimeAgo :time="example.time" v-slot="{ timeAgo }">
          <div class="example-time">{{ timeAgo }}</div>
        </UseTimeAgo>
      </div>
    </div>
  </div>

  <!-- Custom messages -->
  <UseTimeAgo 
    :time="customTime" 
    :messages="customMessages"
    v-slot="{ timeAgo }"
  >
    <div class="custom-time-ago">
      <h3>Custom Messages</h3>
      <p>{{ timeAgo }}</p>
    </div>
  </UseTimeAgo>

  <!-- Activity feed -->
  <div class="activity-feed">
    <h3>Activity Feed</h3>
    <div class="activities">
      <div v-for="activity in activities" :key="activity.id" class="activity-item">
        <div class="activity-icon">{{ activity.icon }}</div>
        <div class="activity-content">
          <div class="activity-text">{{ activity.text }}</div>
          <UseTimeAgo :time="activity.timestamp" v-slot="{ timeAgo }">
            <div class="activity-time">{{ timeAgo }}</div>
          </UseTimeAgo>
        </div>
      </div>
    </div>
  </div>

  <!-- Blog posts with different formats -->
  <div class="blog-demo">
    <h3>Blog Posts</h3>
    <div class="posts">
      <div v-for="post in blogPosts" :key="post.id" class="post-item">
        <h4>{{ post.title }}</h4>
        <p>{{ post.excerpt }}</p>
        <div class="post-meta">
          <span>By {{ post.author }}</span>
          <UseTimeAgo 
            :time="post.publishedAt"
            :max="'week'"
            :full-date-formatter="formatFullDate"
            v-slot="{ timeAgo }"
          >
            <span class="post-time">{{ timeAgo }}</span>
          </UseTimeAgo>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import { UseTimeAgo } from '@vueuse/components';

const now = Date.now();

const timeExamples = [
  { label: 'Just now', time: now - 5000 },
  { label: '2 minutes ago', time: now - 2 * 60 * 1000 },
  { label: '1 hour ago', time: now - 60 * 60 * 1000 },
  { label: 'Yesterday', time: now - 25 * 60 * 60 * 1000 },
  { label: 'Last week', time: now - 8 * 24 * 60 * 60 * 1000 },
  { label: 'Last month', time: now - 35 * 24 * 60 * 60 * 1000 }
];

const customTime = ref(now - 30 * 60 * 1000);

const customMessages = {
  justNow: 'right now',
  past: n => `${n} ago`,
  future: n => `in ${n}`,
  second: ['second', 'seconds'],
  minute: ['minute', 'minutes'],
  hour: ['hour', 'hours'],
  day: ['day', 'days'],
  week: ['week', 'weeks'],
  month: ['month', 'months'],
  year: ['year', 'years']
};

const activities = ref([
  {
    id: 1,
    icon: '📝',
    text: 'John created a new document',
    timestamp: now - 5 * 60 * 1000
  },
  {
    id: 2,
    icon: '💬',
    text: 'Sarah commented on your post',
    timestamp: now - 15 * 60 * 1000
  },
  {
    id: 3,
    icon: '❤️',
    text: 'Mike liked your photo',
    timestamp: now - 2 * 60 * 60 * 1000
  },
  {
    id: 4,
    icon: '🔗',
    text: 'Anna shared your article',
    timestamp: now - 6 * 60 * 60 * 1000
  },
  {
    id: 5,
    icon: '🎉',
    text: 'You reached 100 followers!',
    timestamp: now - 2 * 24 * 60 * 60 * 1000
  }
]);

const blogPosts = ref([
  {
    id: 1,
    title: 'Getting Started with Vue 3',
    excerpt: 'Learn the basics of Vue 3 composition API...',
    author: 'Alex Chen',
    publishedAt: now - 3 * 24 * 60 * 60 * 1000
  },
  {
    id: 2,
    title: 'Advanced TypeScript Tips',
    excerpt: 'Discover advanced TypeScript patterns...',
    author: 'Maria Garcia',
    publishedAt: now - 10 * 24 * 60 * 60 * 1000
  },
  {
    id: 3,
    title: 'Building Scalable Applications',
    excerpt: 'Best practices for large-scale development...',
    author: 'David Kim',
    publishedAt: now - 45 * 24 * 60 * 60 * 1000
  }
]);

function formatFullDate(date) {
  return date.toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  });
}
</script>

<style>
.time-ago-demo, .custom-time-ago, .activity-feed, .blog-demo {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 20px;
  margin: 15px 0;
}

.time-examples {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 15px;
  margin-top: 15px;
}

.time-example {
  padding: 15px;
  border: 1px solid #eee;
  border-radius: 6px;
  background: #f9f9f9;
}

.example-label {
  font-weight: bold;
  color: #666;
  margin-bottom: 8px;
}

.example-time {
  color: #2196f3;
  font-size: 1.1em;
}

.custom-time-ago {
  text-align: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
}

.activities {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-top: 15px;
}

.activity-item {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 15px;
  border: 1px solid #eee;
  border-radius: 8px;
  background: #fafafa;
}

.activity-icon {
  font-size: 1.5em;
  flex-shrink: 0;
}

.activity-content {
  flex: 1;
}

.activity-text {
  font-weight: 500;
  margin-bottom: 4px;
}

.activity-time {
  font-size: 0.9em;
  color: #666;
}

.posts {
  display: flex;
  flex-direction: column;
  gap: 20px;
  margin-top: 15px;
}

.post-item {
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
  background: white;
}

.post-item h4 {
  margin: 0 0 10px 0;
  color: #333;
}

.post-item p {
  margin: 0 0 15px 0;
  color: #666;
  line-height: 1.5;
}

.post-meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 0.9em;
  color: #888;
  border-top: 1px solid #eee;
  padding-top: 15px;
}

.post-time {
  font-weight: 500;
}
</style>

UseVirtualList Component

Implements virtual scrolling for large datasets with optimal performance.

/**
 * Component that implements virtual scrolling for large lists
 * @example
 * <UseVirtualList :list="items" :options="{ itemHeight: 50 }" v-slot="{ list, containerProps, wrapperProps }">
 *   <div v-bind="containerProps">
 *     <div v-bind="wrapperProps">
 *       <div v-for="{ data, index } in list" :key="index">{{ data.name }}</div>
 *     </div>
 *   </div>
 * </UseVirtualList>
 */
interface UseVirtualListProps<T = any> {
  /** Array of items to virtualize */
  list: T[];
  /** Virtual list options */
  options: UseVirtualListOptions;
  /** Container height @default 300 */
  height?: number;
}

/** Slot data exposed by UseVirtualList component */
interface UseVirtualListReturn<T = any> {
  /** Visible list items with their data and indices */
  list: ComputedRef<UseVirtualListItem<T>[]>;
  /** Props to bind to the scrollable container */
  containerProps: ComputedRef<UseVirtualListContainerProps>;
  /** Props to bind to the wrapper element */
  wrapperProps: ComputedRef<UseVirtualListWrapperProps>;
  /** Scroll to specific index */
  scrollTo: (index: number) => void;
}

interface UseVirtualListOptions {
  /** Height of each item in pixels @default 50 */
  itemHeight: number | ((index: number) => number);
  /** Number of extra items to render outside viewport @default 5 */
  overscan?: number;
}

interface UseVirtualListItem<T = any> {
  data: T;
  index: number;
}

interface UseVirtualListContainerProps {
  ref: Ref<HTMLElement | undefined>;
  onScroll: (e: Event) => void;
  style: CSSProperties;
}

interface UseVirtualListWrapperProps {
  style: ComputedRef<CSSProperties>;
}

Usage Examples:

<template>
  <!-- Basic virtual list -->
  <div class="virtual-list-demo">
    <h3>Virtual List - {{ largeList.length.toLocaleString() }} Items</h3>
    <UseVirtualList 
      :list="largeList"
      :options="{ itemHeight: 50, overscan: 10 }"
      :height="400"
      v-slot="{ list, containerProps, wrapperProps, scrollTo }"
    >
      <div class="virtual-controls">
        <button @click="scrollTo(0)">Go to Top</button>
        <button @click="scrollTo(Math.floor(largeList.length / 2))">Go to Middle</button>
        <button @click="scrollTo(largeList.length - 1)">Go to Bottom</button>
        <input 
          type="number" 
          :max="largeList.length - 1"
          placeholder="Jump to index"
          @keyup.enter="scrollTo(parseInt($event.target.value))"
        />
      </div>
      <div v-bind="containerProps" class="virtual-container">
        <div v-bind="wrapperProps" class="virtual-wrapper">
          <div 
            v-for="{ data, index } in list" 
            :key="index"
            class="virtual-item"
          >
            <div class="item-index">#{{ index }}</div>
            <div class="item-name">{{ data.name }}</div>
            <div class="item-value">{{ data.value }}</div>
          </div>
        </div>
      </div>
    </UseVirtualList>
  </div>

  <!-- Variable height virtual list -->
  <div class="variable-height-demo">
    <h3>Variable Height Virtual List</h3>
    <UseVirtualList 
      :list="variableHeightList"
      :options="{ itemHeight: getItemHeight, overscan: 3 }"
      :height="350"
      v-slot="{ list, containerProps, wrapperProps }"
    >
      <div v-bind="containerProps" class="variable-container">
        <div v-bind="wrapperProps" class="variable-wrapper">
          <div 
            v-for="{ data, index } in list" 
            :key="index"
            class="variable-item"
            :class="data.type"
          >
            <div class="item-header">
              <span class="item-type">{{ data.type }}</span>
              <span class="item-id">#{{ index }}</span>
            </div>
            <div class="item-content">{{ data.content }}</div>
            <div v-if="data.details" class="item-details">
              {{ data.details }}
            </div>
          </div>
        </div>
      </div>
    </UseVirtualList>
  </div>

  <!-- Chat message virtual list -->
  <div class="chat-demo">
    <h3>Virtual Chat Messages</h3>
    <UseVirtualList 
      :list="chatMessages"
      :options="{ itemHeight: getChatMessageHeight, overscan: 5 }"
      :height="300"
      v-slot="{ list, containerProps, wrapperProps, scrollTo }"
    >
      <div class="chat-controls">
        <button @click="addMessage">Add Message</button>
        <button @click="scrollTo(chatMessages.length - 1)">Scroll to Latest</button>
      </div>
      <div v-bind="containerProps" class="chat-container">
        <div v-bind="wrapperProps" class="chat-wrapper">
          <div 
            v-for="{ data, index } in list" 
            :key="index"
            class="chat-message"
            :class="{ own: data.isOwn }"
          >
            <div class="message-info">
              <span class="message-author">{{ data.author }}</span>
              <span class="message-time">{{ formatChatTime(data.timestamp) }}</span>
            </div>
            <div class="message-text">{{ data.text }}</div>
          </div>
        </div>
      </div>
    </UseVirtualList>
  </div>

  <!-- Performance comparison -->
  <div class="performance-demo">
    <h3>Performance Comparison</h3>
    <div class="performance-controls">
      <button @click="showVirtual = !showVirtual">
        {{ showVirtual ? 'Show Regular List' : 'Show Virtual List' }}
      </button>
      <span class="performance-info">
        Rendering {{ showVirtual ? 'virtualized' : 'all' }} items
      </span>
    </div>
    
    <div v-if="!showVirtual" class="regular-list">
      <div 
        v-for="(item, index) in performanceList.slice(0, 1000)" 
        :key="index"
        class="performance-item"
      >
        {{ item.name }} - {{ item.description }}
      </div>
    </div>
    
    <UseVirtualList 
      v-else
      :list="performanceList"
      :options="{ itemHeight: 60 }"
      :height="400"
      v-slot="{ list, containerProps, wrapperProps }"
    >
      <div v-bind="containerProps" class="performance-container">
        <div v-bind="wrapperProps" class="performance-wrapper">
          <div 
            v-for="{ data, index } in list" 
            :key="index"
            class="performance-item"
          >
            {{ data.name }} - {{ data.description }}
          </div>
        </div>
      </div>
    </UseVirtualList>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import { UseVirtualList } from '@vueuse/components';

// Generate large list
const largeList = ref([]);
for (let i = 0; i < 100000; i++) {
  largeList.value.push({
    name: `Item ${i + 1}`,
    value: Math.floor(Math.random() * 1000)
  });
}

// Variable height list
const variableHeightList = ref([]);
const itemTypes = ['header', 'content', 'footer'];
for (let i = 0; i < 1000; i++) {
  const type = itemTypes[Math.floor(Math.random() * itemTypes.length)];
  variableHeightList.value.push({
    type,
    content: `This is ${type} item ${i + 1}`,
    details: type === 'content' ? `Additional details for item ${i + 1}` : null
  });
}

// Chat messages
const chatMessages = ref([]);
const authors = ['Alice', 'Bob', 'Charlie', 'Diana'];
for (let i = 0; i < 500; i++) {
  const author = authors[Math.floor(Math.random() * authors.length)];
  chatMessages.value.push({
    author,
    text: `Message ${i + 1}: ${generateRandomMessage()}`,
    timestamp: Date.now() - (500 - i) * 60000,
    isOwn: author === 'Alice'
  });
}

// Performance list
const performanceList = ref([]);
for (let i = 0; i < 50000; i++) {
  performanceList.value.push({
    name: `Performance Item ${i + 1}`,
    description: `This is a description for performance testing item number ${i + 1}`
  });
}

const showVirtual = ref(true);

function getItemHeight(index) {
  const item = variableHeightList.value[index];
  if (!item) return 50;
  
  switch (item.type) {
    case 'header': return 80;
    case 'content': return item.details ? 120 : 80;
    case 'footer': return 60;
    default: return 50;
  }
}

function getChatMessageHeight(index) {
  const message = chatMessages.value[index];
  if (!message) return 60;
  
  // Estimate height based on message length
  const baseHeight = 60;
  const extraHeight = Math.ceil(message.text.length / 50) * 20;
  return Math.min(baseHeight + extraHeight, 150);
}

function addMessage() {
  const newMessage = {
    author: 'Alice',
    text: `New message: ${generateRandomMessage()}`,
    timestamp: Date.now(),
    isOwn: true
  };
  chatMessages.value.push(newMessage);
}

function generateRandomMessage() {
  const messages = [
    "Hello everyone!",
    "How are you doing today?",
    "This is a test message.",
    "Virtual scrolling is amazing!",
    "Performance is great with large lists.",
    "Check out this new feature.",
    "What do you think about this implementation?",
    "Looking forward to your feedback!"
  ];
  return messages[Math.floor(Math.random() * messages.length)];
}

function formatChatTime(timestamp) {
  return new Date(timestamp).toLocaleTimeString();
}
</script>

<style>
.virtual-list-demo, .variable-height-demo, .chat-demo, .performance-demo {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 20px;
  margin: 15px 0;
}

.virtual-controls, .chat-controls, .performance-controls {
  display: flex;
  gap: 10px;
  align-items: center;
  margin-bottom: 15px;
  flex-wrap: wrap;
}

.virtual-controls button, .chat-controls button, .performance-controls button {
  padding: 6px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  cursor: pointer;
  background: #f5f5f5;
}

.virtual-controls input {
  padding: 6px 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  width: 120px;
}

.virtual-container, .variable-container, .chat-container, .performance-container {
  border: 2px solid #eee;
  border-radius: 6px;
  background: #fafafa;
}

.virtual-item, .variable-item, .performance-item {
  display: flex;
  align-items: center;
  gap: 15px;
  padding: 15px;
  border-bottom: 1px solid #eee;
  background: white;
}

.virtual-item:hover, .variable-item:hover, .performance-item:hover {
  background: #f0f8ff;
}

.item-index {
  font-weight: bold;
  color: #666;
  min-width: 60px;
}

.item-name {
  font-weight: bold;
  flex: 1;
}

.item-value {
  color: #2196f3;
  font-family: monospace;
}

.variable-item.header {
  background: #e3f2fd;
  font-weight: bold;
}

.variable-item.footer {
  background: #f3e5f5;
  font-style: italic;
}

.item-header {
  display: flex;
  justify-content: space-between;
  width: 100%;
  align-items: center;
}

.item-type {
  text-transform: uppercase;
  font-size: 0.8em;
  font-weight: bold;
  padding: 2px 8px;
  border-radius: 4px;
  background: #ddd;
}

.item-content {
  margin-top: 8px;
}

.item-details {
  margin-top: 5px;
  font-size: 0.9em;
  color: #666;
}

.chat-message {
  padding: 12px 15px;
  border-bottom: 1px solid #eee;
  background: white;
}

.chat-message.own {
  background: #e8f5e8;
}

.message-info {
  display: flex;
  justify-content: space-between;
  margin-bottom: 5px;
  font-size: 0.9em;
}

.message-author {
  font-weight: bold;
  color: #333;
}

.message-time {
  color: #666;
}

.message-text {
  line-height: 1.4;
}

.regular-list {
  height: 400px;
  border: 2px solid #eee;
  border-radius: 6px;
  overflow-y: auto;
  background: #fafafa;
}

.performance-info {
  font-size: 0.9em;
  color: #666;
  font-style: italic;
}
</style>

UseOffsetPagination Component

Manages offset-based pagination with reactive state and event emissions.

/**
 * Component that manages offset-based pagination
 * @example
 * <UseOffsetPagination 
 *   v-slot="{ currentPage, pageSize, pageCount, isFirstPage, isLastPage, prev, next }"
 *   :total="1000" 
 *   :page-size="20"
 * >
 *   <div>Page {{ currentPage }} of {{ pageCount }}</div>
 * </UseOffsetPagination>
 */
interface UseOffsetPaginationProps {
  /** Total number of items */
  total: MaybeRefOrGetter<number>;
  /** Number of items per page @default 10 */
  pageSize?: MaybeRefOrGetter<number>;
  /** Current page number @default 1 */
  page?: MaybeRefOrGetter<number>;
  /** Callback when page changes */
  onPageChange?: (returnValue: UseOffsetPaginationReturn) => unknown;
  /** Callback when page size changes */
  onPageSizeChange?: (returnValue: UseOffsetPaginationReturn) => unknown;
  /** Callback when page count changes */
  onPageCountChange?: (returnValue: UseOffsetPaginationReturn) => unknown;
}

/** Slot data exposed by UseOffsetPagination component */
interface UseOffsetPaginationReturn {
  /** Current page number */
  currentPage: Ref<number>;
  /** Current page size */
  pageSize: Ref<number>;
  /** Total number of pages */
  pageCount: ComputedRef<number>;
  /** Whether on first page */
  isFirstPage: ComputedRef<boolean>;
  /** Whether on last page */
  isLastPage: ComputedRef<boolean>;
  /** Go to previous page */
  prev: () => void;
  /** Go to next page */
  next: () => void;
  /** Go to specific page */
  goToPage: (page: number) => void;
}

/** Component events */
interface UseOffsetPaginationEmits {
  /** Emitted when page changes */
  'page-change': (returnValue: UseOffsetPaginationReturn) => void;
  /** Emitted when page size changes */
  'page-size-change': (returnValue: UseOffsetPaginationReturn) => void;
  /** Emitted when page count changes */
  'page-count-change': (returnValue: UseOffsetPaginationReturn) => void;
}

Usage Examples:

<template>
  <!-- Basic pagination -->
  <div class="pagination-demo">
    <h3>Basic Pagination</h3>
    <UseOffsetPagination 
      :total="500"
      :page-size="20"
      v-slot="{ currentPage, pageSize, pageCount, isFirstPage, isLastPage, prev, next, goToPage }"
      @page-change="handlePageChange"
    >
      <div class="data-section">
        <div class="data-info">
          Showing {{ (currentPage - 1) * pageSize + 1 }}-{{ Math.min(currentPage * pageSize, 500) }} of 500 items
        </div>
        <div class="data-grid">
          <div v-for="item in getCurrentPageData(currentPage, pageSize)" :key="item.id" class="data-item">
            {{ item.name }} - {{ item.description }}
          </div>
        </div>
      </div>
      
      <div class="pagination-controls">
        <button @click="prev" :disabled="isFirstPage" class="nav-btn">
          ← Previous
        </button>
        
        <div class="page-numbers">
          <button 
            v-for="page in getVisiblePages(currentPage, pageCount)"
            :key="page"
            @click="goToPage(page)"
            :class="{ active: page === currentPage, ellipsis: page === '...' }"
            :disabled="page === '...'"
            class="page-btn"
          >
            {{ page }}
          </button>
        </div>
        
        <button @click="next" :disabled="isLastPage" class="nav-btn">
          Next →
        </button>
      </div>
      
      <div class="pagination-info">
        Page {{ currentPage }} of {{ pageCount }}
      </div>
    </UseOffsetPagination>
  </div>

  <!-- Advanced pagination with size selector -->
  <div class="advanced-pagination">
    <h3>Advanced Pagination</h3>
    <UseOffsetPagination 
      :total="advancedTotal"
      :page-size="advancedPageSize"
      :page="advancedPage"
      v-slot="pagination"
      @page-change="handleAdvancedPageChange"
      @page-size-change="handlePageSizeChange"
    >
      <div class="advanced-controls">
        <label class="page-size-selector">
          Items per page:
          <select v-model="advancedPageSize">
            <option :value="10">10</option>
            <option :value="25">25</option>
            <option :value="50">50</option>
            <option :value="100">100</option>
          </select>
        </label>
        
        <div class="jump-to-page">
          <label>
            Jump to page:
            <input 
              type="number" 
              :min="1" 
              :max="pagination.pageCount" 
              @keyup.enter="pagination.goToPage(parseInt($event.target.value))"
              class="page-input"
            />
          </label>
        </div>
      </div>
      
      <div class="advanced-data">
        <div class="results-summary">
          Results {{ (pagination.currentPage - 1) * pagination.pageSize + 1 }}-{{ 
            Math.min(pagination.currentPage * pagination.pageSize, advancedTotal) 
          }} of {{ advancedTotal.toLocaleString() }}
        </div>
        
        <table class="data-table">
          <thead>
            <tr>
              <th>ID</th>
              <th>Name</th>
              <th>Category</th>
              <th>Status</th>
              <th>Created</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="item in getAdvancedPageData(pagination.currentPage, pagination.pageSize)" :key="item.id">
              <td>{{ item.id }}</td>
              <td>{{ item.name }}</td>
              <td>{{ item.category }}</td>
              <td>
                <span :class="`status-${item.status}`" class="status-badge">
                  {{ item.status }}
                </span>
              </td>
              <td>{{ formatDate(item.created) }}</td>
            </tr>
          </tbody>
        </table>
        
        <div class="table-pagination">
          <button @click="pagination.prev" :disabled="pagination.isFirstPage">
            ← Prev
          </button>
          <span class="page-info">
            {{ pagination.currentPage }} / {{ pagination.pageCount }}
          </span>
          <button @click="pagination.next" :disabled="pagination.isLastPage">
            Next →
          </button>
        </div>
      </div>
    </UseOffsetPagination>
  </div>

  <!-- Search with pagination -->
  <div class="search-pagination">
    <h3>Search with Pagination</h3>
    <div class="search-controls">
      <input 
        v-model="searchQuery"
        placeholder="Search items..."
        class="search-input"
        @input="handleSearch"
      />
      <button @click="clearSearch" class="clear-btn">Clear</button>
    </div>
    
    <UseOffsetPagination 
      :total="filteredTotal"
      :page-size="10"
      :page="searchPage"
      v-slot="pagination"
      @page-change="handleSearchPageChange"
    >
      <div v-if="searchQuery" class="search-results-info">
        Found {{ filteredTotal }} results for "{{ searchQuery }}"
      </div>
      
      <div class="search-results">
        <div v-if="filteredTotal === 0" class="no-results">
          No results found for "{{ searchQuery }}"
        </div>
        <div v-else class="results-list">
          <div 
            v-for="item in getFilteredPageData(pagination.currentPage, pagination.pageSize)" 
            :key="item.id"
            class="result-item"
          >
            <div class="result-title">{{ highlightSearchTerm(item.name, searchQuery) }}</div>
            <div class="result-description">{{ highlightSearchTerm(item.description, searchQuery) }}</div>
          </div>
        </div>
      </div>
      
      <div v-if="filteredTotal > 0" class="search-pagination-controls">
        <button @click="pagination.prev" :disabled="pagination.isFirstPage">
          ← Previous
        </button>
        <span>{{ pagination.currentPage }} of {{ pagination.pageCount }}</span>
        <button @click="pagination.next" :disabled="pagination.isLastPage">
          Next →
        </button>
      </div>
    </UseOffsetPagination>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import { UseOffsetPagination } from '@vueuse/components';

// Basic pagination data
const basicData = ref([]);
for (let i = 1; i <= 500; i++) {
  basicData.value.push({
    id: i,
    name: `Item ${i}`,
    description: `Description for item ${i}`
  });
}

// Advanced pagination data
const advancedPageSize = ref(25);
const advancedPage = ref(1);
const advancedTotal = 2547;

const advancedData = ref([]);
const categories = ['Electronics', 'Clothing', 'Books', 'Home & Garden', 'Sports'];
const statuses = ['active', 'inactive', 'pending'];

for (let i = 1; i <= advancedTotal; i++) {
  advancedData.value.push({
    id: i,
    name: `Product ${i}`,
    category: categories[Math.floor(Math.random() * categories.length)],
    status: statuses[Math.floor(Math.random() * statuses.length)],
    created: new Date(Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000)
  });
}

// Search pagination
const searchQuery = ref('');
const searchPage = ref(1);
const searchData = ref([]);

// Initialize search data
for (let i = 1; i <= 1000; i++) {
  searchData.value.push({
    id: i,
    name: `Item ${i} - ${generateRandomWords()}`,
    description: `This is a detailed description for item ${i}. ${generateRandomDescription()}`
  });
}

const filteredData = computed(() => {
  if (!searchQuery.value) return searchData.value;
  
  const query = searchQuery.value.toLowerCase();
  return searchData.value.filter(item => 
    item.name.toLowerCase().includes(query) || 
    item.description.toLowerCase().includes(query)
  );
});

const filteredTotal = computed(() => filteredData.value.length);

function getCurrentPageData(page, pageSize) {
  const start = (page - 1) * pageSize;
  const end = start + pageSize;
  return basicData.value.slice(start, end);
}

function getAdvancedPageData(page, pageSize) {
  const start = (page - 1) * pageSize;
  const end = start + pageSize;
  return advancedData.value.slice(start, end);
}

function getFilteredPageData(page, pageSize) {
  const start = (page - 1) * pageSize;
  const end = start + pageSize;
  return filteredData.value.slice(start, end);
}

function getVisiblePages(currentPage, totalPages) {
  const pages = [];
  const maxVisible = 7;
  
  if (totalPages <= maxVisible) {
    for (let i = 1; i <= totalPages; i++) {
      pages.push(i);
    }
  } else {
    pages.push(1);
    
    if (currentPage > 4) {
      pages.push('...');
    }
    
    const start = Math.max(2, currentPage - 1);
    const end = Math.min(totalPages - 1, currentPage + 1);
    
    for (let i = start; i <= end; i++) {
      pages.push(i);
    }
    
    if (currentPage < totalPages - 3) {
      pages.push('...');
    }
    
    if (totalPages > 1) {
      pages.push(totalPages);
    }
  }
  
  return pages;
}

function handlePageChange(pagination) {
  console.log('Page changed:', pagination.currentPage);
}

function handleAdvancedPageChange(pagination) {
  advancedPage.value = pagination.currentPage;
}

function handlePageSizeChange(pagination) {
  advancedPageSize.value = pagination.pageSize;
  advancedPage.value = 1; // Reset to first page
}

function handleSearch() {
  searchPage.value = 1; // Reset to first page on search
}

function handleSearchPageChange(pagination) {
  searchPage.value = pagination.currentPage;
}

function clearSearch() {
  searchQuery.value = '';
  searchPage.value = 1;
}

function highlightSearchTerm(text, query) {
  if (!query) return text;
  
  const regex = new RegExp(`(${query})`, 'gi');
  return text.replace(regex, '<mark>$1</mark>');
}

function formatDate(date) {
  return date.toLocaleDateString();
}

function generateRandomWords() {
  const words = ['Amazing', 'Fantastic', 'Great', 'Awesome', 'Cool', 'Nice', 'Super', 'Mega'];
  return words[Math.floor(Math.random() * words.length)];
}

function generateRandomDescription() {
  const descriptions = [
    'High quality product with excellent features.',
    'Perfect for everyday use and special occasions.',
    'Durable construction with modern design.',
    'Affordable pricing with premium quality.',
    'Innovative technology meets practical design.'
  ];
  return descriptions[Math.floor(Math.random() * descriptions.length)];
}
</script>

<style>
.pagination-demo, .advanced-pagination, .search-pagination {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 20px;
  margin: 15px 0;
}

.data-section {
  margin-bottom: 20px;
}

.data-info {
  margin-bottom: 15px;
  font-weight: 500;
  color: #666;
}

.data-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 10px;
  margin-bottom: 20px;
}

.data-item, .result-item {
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  background: #f9f9f9;
}

.pagination-controls, .table-pagination, .search-pagination-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  margin: 20px 0;
}

.nav-btn, .page-btn {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  cursor: pointer;
  background: white;
}

.nav-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.page-numbers {
  display: flex;
  gap: 2px;
}

.page-btn.active {
  background: #2196f3;
  color: white;
  border-color: #2196f3;
}

.page-btn.ellipsis {
  cursor: default;
  background: transparent;
  border: none;
}

.pagination-info {
  text-align: center;
  color: #666;
  font-size: 0.9em;
}

.advanced-controls {
  display: flex;
  gap: 20px;
  align-items: center;
  margin-bottom: 20px;
  flex-wrap: wrap;
}

.page-size-selector select, .page-input {
  margin-left: 8px;
  padding: 4px 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.page-input {
  width: 80px;
}

.data-table {
  width: 100%;
  border-collapse: collapse;
  margin: 15px 0;
}

.data-table th, .data-table td {
  padding: 12px;
  text-align: left;
  border-bottom: 1px solid #ddd;
}

.data-table th {
  background: #f5f5f5;
  font-weight: 600;
}

.status-badge {
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 0.8em;
  font-weight: 500;
  text-transform: uppercase;
}

.status-active {
  background: #e8f5e8;
  color: #2e7d32;
}

.status-inactive {
  background: #ffebee;
  color: #c62828;
}

.status-pending {
  background: #fff3e0;
  color: #f57c00;
}

.search-controls {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

.search-input {
  flex: 1;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
}

.clear-btn {
  padding: 10px 15px;
  border: 1px solid #ddd;
  border-radius: 4px;
  cursor: pointer;
  background: #f5f5f5;
}

.search-results-info {
  margin-bottom: 15px;
  padding: 10px;
  background: #e3f2fd;
  border-radius: 4px;
  color: #1976d2;
}

.no-results {
  text-align: center;
  padding: 40px;
  color: #666;
  font-style: italic;
}

.results-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.result-title {
  font-weight: bold;
  margin-bottom: 5px;
}

.result-description {
  color: #666;
  font-size: 0.9em;
}

:global(mark) {
  background: #ffeb3b;
  padding: 0 2px;
  border-radius: 2px;
}
</style>

Additional Utility Components

UseNetwork Component

/**
 * Component that provides network connectivity information
 */
interface UseNetworkReturn {
  isOnline: Ref<boolean>;
  downlink: Ref<number | undefined>;
  downlinkMax: Ref<number | undefined>;
  effectiveType: Ref<string | undefined>;
  saveData: Ref<boolean | undefined>;
  type: Ref<string | undefined>;
}

UseOnline Component

/**
 * Component that tracks online/offline status
 */
interface UseOnlineReturn {
  isOnline: Ref<boolean>;
}

UseIdle Component

/**
 * Component that tracks user idle state
 */
interface UseIdleReturn {
  idle: Ref<boolean>;
  lastActive: Ref<number>;
  reset: () => void;
}

UseImage Component

/**
 * Component that manages image loading state
 */
interface UseImageReturn {
  isLoading: Ref<boolean>;
  error: Ref<Event | null>;
  isReady: Ref<boolean>;
}

UseObjectUrl Component

/**
 * Component that creates and manages object URLs
 */
interface UseObjectUrlReturn {
  url: Ref<string | undefined>;
  release: () => void;
}

Type Definitions

/** Common types used across utility and advanced components */
type MaybeRefOrGetter<T> = T | Ref<T> | (() => T);

interface RenderableComponent {
  /** The element that the component should be rendered as @default 'div' */
  as?: object | string;
}

/** Time and timestamp types */
interface UseTimestampOptions {
  offset?: number;
  interval?: number | 'requestAnimationFrame';
  immediate?: boolean;
  controls?: boolean;
}

/** Pagination types */
interface UseOffsetPaginationOptions {
  total: MaybeRefOrGetter<number>;
  pageSize?: MaybeRefOrGetter<number>;
  page?: MaybeRefOrGetter<number>;
}

/** Virtual list types */
interface UseVirtualListItem<T = any> {
  data: T;
  index: number;
}

/** Network information types */
interface NetworkInformation extends EventTarget {
  readonly connection?: NetworkConnection;
  readonly onLine: boolean;
}

interface NetworkConnection {
  readonly downlink: number;
  readonly downlinkMax: number;
  readonly effectiveType: '2g' | '3g' | '4g' | 'slow-2g';
  readonly rtt: number;
  readonly saveData: boolean;
  readonly type: ConnectionType;
}

type ConnectionType = 'bluetooth' | 'cellular' | 'ethernet' | 'mixed' | 'none' | 'other' | 'unknown' | 'wifi' | 'wimax';

Install with Tessl CLI

npx tessl i tessl/npm-vueuse--components

docs

browser-apis.md

browser-events.md

device-sensors.md

element-tracking.md

index.md

mouse-pointer.md

scroll-resize.md

theme-preferences.md

utilities-advanced.md

window-document.md

tile.json