or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

custom-editors.mdcustom-renderers.mdhelper-functions.mdhot-column.mdhot-table.mdindex.md
tile.json

custom-renderers.mddocs/

Custom Cell Renderers

Vue-based custom cell renderers allow you to create rich, interactive cell content using Vue components. Renderers control how cell data is displayed and can include complex HTML, styling, and even interactive elements.

Capabilities

Renderer Component Integration

System for integrating Vue components as Handsontable cell renderers.

/**
 * Props automatically passed to renderer components
 */
interface RendererProps {
  /** Handsontable instance */
  hotInstance: Handsontable;
  
  /** Table cell DOM element */
  TD: HTMLTableCellElement;
  
  /** Row index of the cell */
  row: number;
  
  /** Column index of the cell */
  col: number;
  
  /** Property name for the cell data */
  prop: string;
  
  /** Current cell value */
  value: any;
  
  /** Cell properties configuration */
  cellProperties: Handsontable.CellProperties;
  
  /** Marker indicating this is a renderer component */
  isRenderer: true;
}

Global Renderer

Renderer applied to all cells in the table unless overridden by column-specific renderers.

Usage with HotTable:

<template>
  <hot-table :data="tableData">
    <!-- Global renderer affects all cells -->
    <global-cell-renderer hot-renderer></global-cell-renderer>
  </hot-table>
</template>

<script>
import GlobalCellRenderer from './GlobalCellRenderer.vue';

export default {
  components: {
    GlobalCellRenderer
  },
  data() {
    return {
      tableData: [
        ['Value 1', 'Value 2'],
        ['Value 3', 'Value 4']
      ]
    };
  }
}
</script>

Column-Specific Renderers

Renderers applied to specific columns using HotColumn components.

Usage with HotColumn:

<template>
  <hot-table :data="productData">
    <!-- Standard column -->
    <hot-column title="Product Name" data="name" width="150" />
    
    <!-- Column with custom renderer -->
    <hot-column title="Price" data="price" width="120">
      <price-renderer hot-renderer></price-renderer>
    </hot-column>
    
    <!-- Column with status renderer -->
    <hot-column title="Status" data="status" width="100">
      <status-badge-renderer hot-renderer></status-badge-renderer>
    </hot-column>
    
    <!-- Column with image renderer -->
    <hot-column title="Image" data="imageUrl" width="80">
      <image-renderer hot-renderer></image-renderer>
    </hot-column>
  </hot-table>
</template>

<script>
import { HotTable, HotColumn } from '@handsontable/vue';
import PriceRenderer from './PriceRenderer.vue';
import StatusBadgeRenderer from './StatusBadgeRenderer.vue';
import ImageRenderer from './ImageRenderer.vue';

export default {
  components: {
    HotTable,
    HotColumn,
    PriceRenderer,
    StatusBadgeRenderer,
    ImageRenderer
  },
  data() {
    return {
      productData: [
        { name: 'Laptop', price: 999.99, status: 'active', imageUrl: '/laptop.jpg' },
        { name: 'Mouse', price: 29.99, status: 'discontinued', imageUrl: '/mouse.jpg' }
      ]
    };
  }
}
</script>

Renderer Component Examples

Simple Text Renderer

Basic renderer that formats text with custom styling:

<template>
  <div class="custom-text-cell" :class="textClass">
    {{ formattedValue }}
  </div>
</template>

<script>
export default {
  props: {
    value: null,
    row: Number,
    col: Number,
    cellProperties: Object
  },
  
  computed: {
    formattedValue() {
      if (typeof this.value === 'string') {
        return this.value.toUpperCase();
      }
      return this.value || '';
    },
    
    textClass() {
      return {
        'text-highlight': this.value && this.value.length > 10,
        'text-warning': this.row % 2 === 0
      };
    }
  }
}
</script>

<style scoped>
.custom-text-cell {
  padding: 4px;
  font-weight: bold;
}

.text-highlight {
  background-color: #e8f5e8;
  color: #2d5a2d;
}

.text-warning {
  background-color: #fff3cd;
  color: #856404;
}
</style>

Price Renderer

Renderer for formatting currency values:

<template>
  <div class="price-cell" :class="priceClass">
    <span class="currency-symbol">$</span>
    <span class="price-value">{{ formattedPrice }}</span>
    <span v-if="showDiscount" class="discount-badge">SALE</span>
  </div>
</template>

<script>
export default {
  props: {
    value: [Number, String],
    cellProperties: Object,
    hotInstance: Object
  },
  
  computed: {
    numericValue() {
      return parseFloat(this.value) || 0;
    },
    
    formattedPrice() {
      return this.numericValue.toLocaleString('en-US', {
        minimumFractionDigits: 2,
        maximumFractionDigits: 2
      });
    },
    
    priceClass() {
      return {
        'price-high': this.numericValue > 500,
        'price-medium': this.numericValue > 100 && this.numericValue <= 500,
        'price-low': this.numericValue <= 100
      };
    },
    
    showDiscount() {
      // Show discount badge if original price is in cell metadata
      return this.cellProperties.originalPrice && 
             this.cellProperties.originalPrice > this.numericValue;
    }
  }
}
</script>

<style scoped>
.price-cell {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 2px 4px;
  font-family: monospace;
}

.currency-symbol {
  color: #666;
  margin-right: 2px;
}

.price-value {
  font-weight: bold;
}

.price-high .price-value {
  color: #e74c3c;
}

.price-medium .price-value {
  color: #f39c12;
}

.price-low .price-value {
  color: #27ae60;
}

.discount-badge {
  background: #e74c3c;
  color: white;
  font-size: 10px;
  padding: 1px 4px;
  border-radius: 2px;
  font-weight: bold;
}
</style>

Status Badge Renderer

Renderer for displaying status badges with icons:

<template>
  <div class="status-badge" :class="statusClass">
    <i :class="statusIcon"></i>
    <span class="status-text">{{ statusText }}</span>
  </div>
</template>

<script>
export default {
  props: {
    value: String,
    row: Number,
    hotInstance: Object
  },
  
  computed: {
    normalizedStatus() {
      return (this.value || '').toLowerCase();
    },
    
    statusClass() {
      return `status-${this.normalizedStatus}`;
    },
    
    statusText() {
      const statusMap = {
        'active': 'Active',
        'inactive': 'Inactive',
        'pending': 'Pending',
        'discontinued': 'Discontinued',
        'draft': 'Draft'
      };
      return statusMap[this.normalizedStatus] || this.value;
    },
    
    statusIcon() {
      const iconMap = {
        'active': 'icon-check-circle',
        'inactive': 'icon-x-circle',
        'pending': 'icon-clock',
        'discontinued': 'icon-trash',
        'draft': 'icon-edit'
      };
      return iconMap[this.normalizedStatus] || 'icon-help';
    }
  }
}
</script>

<style scoped>
.status-badge {
  display: inline-flex;
  align-items: center;
  padding: 2px 6px;
  border-radius: 12px;
  font-size: 11px;
  font-weight: 500;
  gap: 4px;
}

.status-active {
  background-color: #d4edda;
  color: #155724;
  border: 1px solid #c3e6cb;
}

.status-inactive {
  background-color: #f8d7da;
  color: #721c24;
  border: 1px solid #f1b0b7;
}

.status-pending {
  background-color: #fff3cd;
  color: #856404;
  border: 1px solid #ffeaa7;
}

.status-discontinued {
  background-color: #e2e3e5;
  color: #383d41;
  border: 1px solid #d6d8db;
}

.status-draft {
  background-color: #cce7ff;
  color: #004085;
  border: 1px solid #99d1ff;
}
</style>

Interactive Progress Renderer

Renderer with interactive elements and data binding:

<template>
  <div class="progress-cell" @click="handleClick">
    <div class="progress-bar">
      <div 
        class="progress-fill" 
        :style="{ width: progressWidth }"
        :class="progressClass"
      ></div>
    </div>
    <span class="progress-text">{{ displayText }}</span>
  </div>
</template>

<script>
export default {
  props: {
    value: [Number, String],
    row: Number,
    col: Number,
    hotInstance: Object
  },
  
  computed: {
    numericValue() {
      const num = parseFloat(this.value);
      return isNaN(num) ? 0 : Math.max(0, Math.min(100, num));
    },
    
    progressWidth() {
      return `${this.numericValue}%`;
    },
    
    progressClass() {
      if (this.numericValue >= 80) return 'progress-high';
      if (this.numericValue >= 50) return 'progress-medium';
      return 'progress-low';
    },
    
    displayText() {
      return `${this.numericValue.toFixed(0)}%`;
    }
  },
  
  methods: {
    handleClick() {
      // Example: increment progress on click
      const newValue = Math.min(100, this.numericValue + 10);
      
      // Update the cell value through Handsontable
      if (this.hotInstance) {
        this.hotInstance.setDataAtCell(this.row, this.col, newValue);
      }
    }
  }
}
</script>

<style scoped>
.progress-cell {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 2px 4px;
  cursor: pointer;
}

.progress-cell:hover {
  background-color: #f8f9fa;
}

.progress-bar {
  flex: 1;
  height: 12px;
  background-color: #e9ecef;
  border-radius: 6px;
  overflow: hidden;
}

.progress-fill {
  height: 100%;
  transition: width 0.3s ease;
  border-radius: 6px;
}

.progress-high {
  background-color: #28a745;
}

.progress-medium {
  background-color: #ffc107;
}

.progress-low {
  background-color: #dc3545;
}

.progress-text {
  font-size: 11px;
  font-weight: bold;
  min-width: 30px;
  text-align: right;
}
</style>

Image Renderer

Renderer for displaying images with error handling:

<template>
  <div class="image-cell">
    <img 
      v-if="shouldShowImage && !imageError"
      :src="imageUrl"
      :alt="altText"
      class="cell-image"
      @error="onImageError"
      @load="onImageLoad"
    />
    <div v-else-if="imageError" class="image-error">
      <i class="icon-image-x"></i>
      <span>No image</span>
    </div>
    <div v-else class="image-placeholder">
      <i class="icon-image"></i>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    value: String,
    cellProperties: Object
  },
  
  data() {
    return {
      imageError: false,
      imageLoaded: false
    };
  },
  
  computed: {
    imageUrl() {
      return this.value;
    },
    
    shouldShowImage() {
      return this.imageUrl && this.imageUrl.trim() !== '';
    },
    
    altText() {
      return this.cellProperties?.imageAlt || 'Cell image';
    }
  },
  
  methods: {
    onImageError() {
      this.imageError = true;
    },
    
    onImageLoad() {
      this.imageLoaded = true;
      this.imageError = false;
    }
  },
  
  watch: {
    value() {
      // Reset error state when value changes
      this.imageError = false;
      this.imageLoaded = false;
    }
  }
}
</script>

<style scoped>
.image-cell {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2px;
  height: 100%;
}

.cell-image {
  max-width: 100%;
  max-height: 100%;
  object-fit: cover;
  border-radius: 2px;
}

.image-error,
.image-placeholder {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  color: #6c757d;
  font-size: 10px;
  gap: 2px;
}

.image-error {
  color: #dc3545;
}
</style>

Advanced Renderer Patterns

Conditional Rendering

<template>
  <component 
    :is="rendererComponent" 
    v-bind="rendererProps"
  />
</template>

<script>
import TextRenderer from './TextRenderer.vue';
import NumberRenderer from './NumberRenderer.vue';
import DateRenderer from './DateRenderer.vue';

export default {
  components: {
    TextRenderer,
    NumberRenderer,
    DateRenderer
  },
  
  props: {
    value: null,
    cellProperties: Object
  },
  
  computed: {
    rendererComponent() {
      const type = this.cellProperties?.type || 'text';
      return `${type}Renderer`;
    },
    
    rendererProps() {
      return {
        value: this.value,
        cellProperties: this.cellProperties
      };
    }
  }
}
</script>

Performance Optimization

<template>
  <div class="optimized-renderer">
    {{ computedValue }}
  </div>
</template>

<script>
export default {
  props: {
    value: null,
    row: Number,
    col: Number
  },
  
  computed: {
    computedValue() {
      // Use computed properties for expensive operations
      return this.expensiveCalculation(this.value);
    }
  },
  
  methods: {
    expensiveCalculation(value) {
      // Cached through Vue's computed property system
      return value ? value.toString().split('').reverse().join('') : '';
    }
  }
}
</script>

Renderers provide powerful customization capabilities while maintaining Vue's reactive system and component lifecycle integration with Handsontable's rendering pipeline.