Vue Directives and Event Handling

Module 25: Frontend Frameworks & State Management

Introduction to Vue Directives

Directives are special attributes with the v- prefix that apply reactive behavior to the DOM. They are one of Vue's most powerful features, allowing you to declaratively manipulate the DOM based on your application state.

Think of directives as special instructions you give to HTML elements, like telling a robot what to do under certain conditions. They create a bridge between your data and the DOM, automatically updating the UI when data changes.

graph LR A[Vue Data/State] -->|Reactive Connection| B[Directives] B -->|Manipulate| C[DOM Elements] style A fill:#42b883 style B fill:#64b687 style C fill:#85c79c classDef default stroke:#333,stroke-width:2px;

Anatomy of a Directive

<element
  v-directive:argument.modifier="expression"
></element>

Where:

Core Directives

Vue provides several built-in directives for common tasks:

v-bind

Dynamically binds an attribute to an expression. This is one of the most frequently used directives.

<!-- Full syntax -->
<img v-bind:src="imageUrl" v-bind:alt="imageDescription">

<!-- Shorthand syntax (more common) -->
<img :src="imageUrl" :alt="imageDescription">

Real-world example: Binding dynamic class names based on application state:

<!-- Binding classes with object syntax -->
<div
  :class="{
    'active': isActive,
    'error': hasError,
    'disabled': isDisabled
  }"
>Dynamic Classes</div>

<!-- Binding inline styles -->
<div
  :style="{
    color: textColor,
    fontSize: fontSize + 'px',
    backgroundColor: bgColor
  }"
>Dynamic Styles</div>

In e-commerce applications, v-bind is used extensively to:

v-if, v-else-if, v-else

Conditionally renders elements based on the truthiness of an expression. These directives actually add/remove elements from the DOM.

<div v-if="userType === 'admin'">
  Admin Panel
</div>
<div v-else-if="userType === 'moderator'">
  Moderator Controls
</div>
<div v-else>
  User Content
</div>

Real-world examples:

v-show

Similar to v-if, but toggles the element's CSS display property instead of actually adding/removing it from the DOM.

<div v-show="isVisible">This element stays in the DOM, but is hidden when isVisible is false</div>

When to use v-show vs v-if:

v-for

Renders an element or template block multiple times based on data.

<!-- Basic Array Iteration -->
<ul>
  <li v-for="item in items" :key="item.id">
    {{ item.name }}
  </li>
</ul>

<!-- With Index -->
<ul>
  <li v-for="(item, index) in items" :key="item.id">
    {{ index + 1 }}. {{ item.name }}
  </li>
</ul>

<!-- Object Properties -->
<ul>
  <li v-for="(value, key) in userObject" :key="key">
    {{ key }}: {{ value }}
  </li>
</ul>

<!-- Iterating a Fixed Number of Times -->
<span v-for="n in 5" :key="n">{{ n }}</span>

The :key attribute is crucial for Vue to track each node's identity, allowing it to reuse and reorder elements efficiently. Always use unique keys with v-for.

Real-world examples of v-for:

v-model

Creates two-way data binding on form inputs, textareas, and select elements.

<!-- Text Input -->
<input v-model="message">
<p>Message: {{ message }}</p>

<!-- Checkbox -->
<input type="checkbox" v-model="isChecked">
<p>Checked: {{ isChecked }}</p>

<!-- Radio Buttons -->
<input type="radio" v-model="picked" value="one">
<input type="radio" v-model="picked" value="two">
<p>Picked: {{ picked }}</p>

<!-- Select Dropdown -->
<select v-model="selected">
  <option disabled value="">Please select one</option>
  <option>A</option>
  <option>B</option>
  <option>C</option>
</select>
<p>Selected: {{ selected }}</p>

Under the hood, v-model is syntactic sugar that combines v-bind and v-on:

<!-- This: -->
<input v-model="searchText">

<!-- Is roughly equivalent to: -->
<input
  :value="searchText"
  @input="searchText = $event.target.value"
>

v-model Modifiers

Vue provides modifiers to customize v-model behavior:

<!-- Trim whitespace -->
<input v-model.trim="username">

<!-- Convert to number -->
<input v-model.number="age" type="number">

<!-- Update only after change event (when input loses focus) -->
<input v-model.lazy="message">

Event Handling with v-on

The v-on directive lets you listen to DOM events and run JavaScript when they're triggered.

<!-- Full syntax -->
<button v-on:click="counter += 1">Add 1</button>

<!-- Shorthand syntax (more common) -->
<button @click="counter += 1">Add 1</button>

You can call methods in event handlers:

<button @click="greet">Greet</button>

<script>
export default {
  methods: {
    greet() {
      alert('Hello ' + this.name + '!');
    }
  },
  data() {
    return {
      name: 'Vue.js'
    }
  }
}
</script>

With the Composition API:

<template>
  <button @click="greet">Greet</button>
</template>

<script setup>
import { ref } from 'vue';

const name = ref('Vue.js');

function greet() {
  alert(`Hello ${name.value}!`);
}
</script>

Accessing Event Object

You can access the native DOM event object in your handlers:

<!-- Using $event special variable -->
<button @click="warn('Warning!', $event)">
  Submit
</button>

<!-- In method -->
methods: {
  warn(message, event) {
    // Access event
    if (event) {
      event.preventDefault();
      console.log(event.target.tagName);
    }
    alert(message);
  }
}

Event Modifiers

Vue provides event modifiers to simplify common tasks:

<!-- Stop propagation -->
<button @click.stop="doThis">Stop</button>

<!-- Prevent default -->
<form @submit.prevent="onSubmit">Submit</form>

<!-- Chain modifiers -->
<a @click.stop.prevent="doThat">Stop & Prevent</a>

<!-- Only trigger once -->
<button @click.once="doOnce">Once</button>

<!-- Key modifiers -->
<input @keyup.enter="submit">

<!-- Key AND modifier combinations -->
<input @keyup.alt.enter="clearForm">

<!-- Mouse button modifiers -->
<button @click.right="showContext">Right Click</button>

Real-world applications:

flowchart TD A[User Action] --> B[DOM Event] B --> C{Event Modifiers} C -->|none| D[Original Event Handler] C -->|.stop| E[Stop Propagation] C -->|.prevent| F[Prevent Default] C -->|.capture| G[Use Capture Phase] C -->|.once| H[Handle Once] C -->|.passive| I[Passive Handler] style A fill:#42b883 style B fill:#64b687 style C fill:#85c79c style D fill:#a6d8b1 style E fill:#a6d8b1 style F fill:#a6d8b1 style G fill:#a6d8b1 style H fill:#a6d8b1 style I fill:#a6d8b1 classDef default stroke:#333,stroke-width:2px;

Form Input Binding with v-model

Creating forms in Vue is straightforward thanks to v-model. Let's look at a complete example of a form with validation:

<template>
  <form @submit.prevent="submitForm">
    <div class="form-group">
      <label for="name">Name</label>
      <input 
        id="name"
        v-model.trim="form.name" 
        :class="{ 'error': errors.name }"
      >
      <span v-if="errors.name" class="error-message">{{ errors.name }}</span>
    </div>
    
    <div class="form-group">
      <label for="email">Email</label>
      <input 
        id="email"
        type="email" 
        v-model.trim="form.email" 
        :class="{ 'error': errors.email }"
      >
      <span v-if="errors.email" class="error-message">{{ errors.email }}</span>
    </div>
    
    <div class="form-group">
      <label for="age">Age</label>
      <input 
        id="age"
        type="number" 
        v-model.number="form.age" 
        :class="{ 'error': errors.age }"
      >
      <span v-if="errors.age" class="error-message">{{ errors.age }}</span>
    </div>
    
    <div class="form-group">
      <label for="message">Message</label>
      <textarea 
        id="message"
        v-model="form.message" 
        :class="{ 'error': errors.message }"
      ></textarea>
      <span v-if="errors.message" class="error-message">{{ errors.message }}</span>
    </div>
    
    <div class="form-group">
      <label>
        <input type="checkbox" v-model="form.terms">
        I agree to the terms and conditions
      </label>
      <span v-if="errors.terms" class="error-message">{{ errors.terms }}</span>
    </div>
    
    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? 'Submitting...' : 'Submit' }}
    </button>
  </form>
</template>

<script setup>
import { reactive, ref } from 'vue';

const form = reactive({
  name: '',
  email: '',
  age: null,
  message: '',
  terms: false
});

const errors = reactive({
  name: '',
  email: '',
  age: '',
  message: '',
  terms: ''
});

const isSubmitting = ref(false);

function validateForm() {
  let isValid = true;
  
  // Reset errors
  Object.keys(errors).forEach(key => {
    errors[key] = '';
  });
  
  // Validate name
  if (!form.name) {
    errors.name = 'Name is required';
    isValid = false;
  }
  
  // Validate email
  if (!form.email) {
    errors.email = 'Email is required';
    isValid = false;
  } else if (!/^\S+@\S+\.\S+$/.test(form.email)) {
    errors.email = 'Please enter a valid email address';
    isValid = false;
  }
  
  // Validate age
  if (form.age === null || form.age === '') {
    errors.age = 'Age is required';
    isValid = false;
  } else if (form.age < 18 || form.age > 120) {
    errors.age = 'Age must be between 18 and 120';
    isValid = false;
  }
  
  // Validate message
  if (!form.message) {
    errors.message = 'Message is required';
    isValid = false;
  } else if (form.message.length < 10) {
    errors.message = 'Message must be at least 10 characters';
    isValid = false;
  }
  
  // Validate terms
  if (!form.terms) {
    errors.terms = 'You must agree to the terms and conditions';
    isValid = false;
  }
  
  return isValid;
}

async function submitForm() {
  if (!validateForm()) {
    return;
  }
  
  isSubmitting.value = true;
  
  try {
    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 2000));
    
    // Reset form after successful submission
    Object.keys(form).forEach(key => {
      if (typeof form[key] === 'boolean') {
        form[key] = false;
      } else {
        form[key] = typeof form[key] === 'number' ? null : '';
      }
    });
    
    alert('Form submitted successfully!');
  } catch (error) {
    alert('An error occurred. Please try again.');
    console.error(error);
  } finally {
    isSubmitting.value = false;
  }
}
</script>

<style scoped>
.form-group {
  margin-bottom: 1rem;
}

label {
  display: block;
  margin-bottom: 0.5rem;
}

input, textarea {
  width: 100%;
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.error {
  border-color: red;
}

.error-message {
  color: red;
  font-size: 0.8rem;
  margin-top: 0.25rem;
  display: block;
}

button {
  padding: 0.5rem 1rem;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:disabled {
  opacity: 0.7;
  cursor: not-allowed;
}
</style>

This form demonstrates:

Custom Directives

While Vue's built-in directives cover most use cases, you can create custom directives for specialized DOM manipulations.

// Global custom directive
app.directive('focus', {
  mounted(el) {
    el.focus();
  }
});

// Usage in a component
<input v-focus>

Custom directive with arguments and modifiers:

// Tooltip directive
app.directive('tooltip', {
  mounted(el, binding) {
    const position = binding.arg || 'top';
    const tooltipText = binding.value || 'Default tooltip';
    const showOnClick = binding.modifiers.click || false;
    
    // Create tooltip element
    const tooltip = document.createElement('div');
    tooltip.className = `tooltip tooltip-${position}`;
    tooltip.textContent = tooltipText;
    el.appendChild(tooltip);
    
    // Add necessary event listeners
    if (showOnClick) {
      el.addEventListener('click', () => {
        tooltip.classList.toggle('visible');
      });
    } else {
      el.addEventListener('mouseenter', () => {
        tooltip.classList.add('visible');
      });
      el.addEventListener('mouseleave', () => {
        tooltip.classList.remove('visible');
      });
    }
  }
});

// Usage
<button v-tooltip:bottom.click="'Click to learn more'">Help</button>

Directive Hook Functions

Custom directives have several lifecycle hooks:

Real-world examples of custom directives:

Component Events

In addition to DOM events, Vue components can emit custom events to communicate with parent components.

Emitting Events from Child Components

Options API:

<template>
  <button @click="incrementCounter">{{ count }}</button>
</template>

<script>
export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    incrementCounter() {
      this.count++;
      this.$emit('increment', this.count);
    }
  }
}
</script>

Composition API:

<template>
  <button @click="incrementCounter">{{ count }}</button>
</template>

<script setup>
import { ref, defineEmits } from 'vue';

const count = ref(0);
const emit = defineEmits(['increment']);

function incrementCounter() {
  count.value++;
  emit('increment', count.value);
}
</script>

Listening for Events in Parent Components

<template>
  <div>
    <h2>Parent Counter: {{ parentCount }}</h2>
    <counter-button @increment="updateParentCount" />
  </div>
</template>

<script setup>
import { ref } from 'vue';
import CounterButton from './CounterButton.vue';

const parentCount = ref(0);

function updateParentCount(childValue) {
  parentCount.value = childValue;
  console.log(`Child sent value: ${childValue}`);
}
</script>

Validating Emitted Events

You can define and validate the events a component emits:

// Options API
export default {
  emits: {
    // No validation
    'increment': null,
    
    // With validation
    'submit': (payload) => {
      if (!payload.email) {
        console.warn('Missing email in submit event payload');
        return false;
      }
      return true;
    }
  },
  methods: {
    submitForm() {
      this.$emit('submit', { email: this.email });
    }
  }
}

// Composition API
const emit = defineEmits({
  increment: null,
  submit: (payload) => {
    if (!payload.email) {
      console.warn('Missing email in submit event payload');
      return false;
    }
    return true;
  }
});

Defining your emitted events provides better documentation and allows for validation, making your components more robust and self-documenting.

graph TD A[Parent Component] -->|Props Down| B[Child Component] B -->|Events Up| A style A fill:#42b883 style B fill:#64b687 classDef default stroke:#333,stroke-width:2px;

Putting It All Together: Building an Interactive Component

Let's combine everything we've learned to build a shopping cart line item component:

<template>
  <div class="cart-item" :class="{ 'cart-item--out-of-stock': !isInStock }">
    <div class="cart-item__image">
      <img :src="product.image" :alt="product.name">
    </div>
    
    <div class="cart-item__details">
      <h3>{{ product.name }}</h3>
      <p v-if="product.description">{{ product.description }}</p>
      
      <div class="cart-item__price">
        <span v-if="product.discountedPrice">
          <s>{{ formatPrice(product.price) }}</s>
          {{ formatPrice(product.discountedPrice) }}
        </span>
        <span v-else>
          {{ formatPrice(product.price) }}
        </span>
      </div>
      
      <div v-if="!isInStock" class="cart-item__out-of-stock">
        Out of stock
      </div>
    </div>
    
    <div class="cart-item__quantity" v-if="isInStock">
      <button
        class="quantity-btn"
        @click="updateQuantity(quantity - 1)"
        :disabled="quantity <= 1"
      >
        -
      </button>
      
      <input
        type="number"
        v-model.number="localQuantity"
        min="1"
        :max="product.maxQuantity || 99"
        @blur="onQuantityBlur"
      >
      
      <button
        class="quantity-btn"
        @click="updateQuantity(quantity + 1)"
        :disabled="product.maxQuantity && quantity >= product.maxQuantity"
      >
        +
      </button>
    </div>
    
    <div class="cart-item__subtotal">
      {{ formatPrice(subtotal) }}
    </div>
    
    <button class="cart-item__remove" @click.stop="$emit('remove')">
      ×
    </button>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue';

const props = defineProps({
  product: {
    type: Object,
    required: true
  },
  quantity: {
    type: Number,
    required: true
  }
});

const emit = defineEmits(['update:quantity', 'remove']);

// Local quantity state for the input
const localQuantity = ref(props.quantity);

// Watch for external quantity changes
watch(() => props.quantity, (newQuantity) => {
  localQuantity.value = newQuantity;
});

// Computed properties
const isInStock = computed(() => {
  return props.product.inStock !== false; // Treat undefined as in stock
});

const subtotal = computed(() => {
  const price = props.product.discountedPrice || props.product.price;
  return price * props.quantity;
});

// Methods
function formatPrice(value) {
  return `$${value.toFixed(2)}`;
}

function updateQuantity(newQuantity) {
  if (newQuantity < 1) return;
  
  // Check max quantity if defined
  if (props.product.maxQuantity && newQuantity > props.product.maxQuantity) {
    newQuantity = props.product.maxQuantity;
  }
  
  localQuantity.value = newQuantity;
  emit('update:quantity', newQuantity);
}

function onQuantityBlur() {
  // Handle empty or invalid values
  if (!localQuantity.value || localQuantity.value < 1) {
    localQuantity.value = 1;
  }
  
  // Ensure it's a number and within bounds
  let value = parseInt(localQuantity.value);
  
  if (props.product.maxQuantity && value > props.product.maxQuantity) {
    value = props.product.maxQuantity;
  }
  
  localQuantity.value = value;
  emit('update:quantity', value);
}
</script>

<style scoped>
.cart-item {
  display: flex;
  align-items: center;
  padding: 1rem;
  border-bottom: 1px solid #eee;
  position: relative;
}

.cart-item--out-of-stock {
  opacity: 0.7;
}

.cart-item__image {
  width: 80px;
  height: 80px;
  overflow: hidden;
  margin-right: 1rem;
}

.cart-item__image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.cart-item__details {
  flex: 1;
}

.cart-item__details h3 {
  margin: 0 0 0.5rem 0;
}

.cart-item__price {
  font-weight: bold;
  margin: 0.5rem 0;
}

.cart-item__price s {
  color: #999;
  margin-right: 0.5rem;
}

.cart-item__out-of-stock {
  color: #e74c3c;
  font-weight: bold;
  margin-top: 0.5rem;
}

.cart-item__quantity {
  display: flex;
  align-items: center;
  margin: 0 1.5rem;
}

.cart-item__quantity input {
  width: 3rem;
  text-align: center;
  margin: 0 0.5rem;
}

.quantity-btn {
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #f5f5f5;
  border: 1px solid #ddd;
  border-radius: 4px;
  cursor: pointer;
}

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

.cart-item__subtotal {
  font-weight: bold;
  width: 6rem;
  text-align: right;
}

.cart-item__remove {
  background: none;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
  color: #999;
  margin-left: 1rem;
}

.cart-item__remove:hover {
  color: #e74c3c;
}
</style>

This component demonstrates:

Using the Component

<template>
  <div class="shopping-cart">
    <h2>Your Cart</h2>
    
    <div v-if="cart.items.length === 0" class="empty-cart">
      Your cart is empty. <a href="/products">Continue shopping</a>
    </div>
    
    <div v-else>
      <div class="cart-header">
        <div class="cart-header__product">Product</div>
        <div class="cart-header__quantity">Quantity</div>
        <div class="cart-header__subtotal">Subtotal</div>
        <div class="cart-header__remove"> </div>
      </div>
      
      <cart-item
        v-for="item in cart.items"
        :key="item.id"
        :product="item.product"
        v-model:quantity="item.quantity"
        @remove="removeItem(item.id)"
      />
      
      <div class="cart-footer">
        <div class="cart-total">
          <span>Total:</span>
          <span>{{ formatPrice(cartTotal) }}</span>
        </div>
        
        <button 
          class="checkout-button"
          @click="checkout"
          :disabled="isCheckingOut"
        >
          {{ isCheckingOut ? 'Processing...' : 'Proceed to Checkout' }}
        </button>
      </div>
    </div>
  </div>
</template>

Practice Activity

Interactive Product Configurator

Build a product configurator that allows users to customize a product with various options. The component should:

  1. Display a base product with customizable options (color, size, features, etc.)
  2. Allow users to select options through different input types
  3. Update the displayed product image based on selections
  4. Calculate the price dynamically based on selected options
  5. Show/hide certain options based on other selections
  6. Validate that the configuration is valid before allowing "Add to Cart"

Start with this data structure:

const product = {
  name: 'Customizable Widget',
  basePrice: 49.99,
  baseImage: '/images/widget-default.jpg',
  colors: [
    { id: 'red', name: 'Red', image: '/images/widget-red.jpg', priceModifier: 0 },
    { id: 'blue', name: 'Blue', image: '/images/widget-blue.jpg', priceModifier: 0 },
    { id: 'green', name: 'Green', image: '/images/widget-green.jpg', priceModifier: 5 }
  ],
  sizes: [
    { id: 'small', name: 'Small', priceModifier: -5 },
    { id: 'medium', name: 'Medium', priceModifier: 0 },
    { id: 'large', name: 'Large', priceModifier: 10 }
  ],
  features: [
    { id: 'wifi', name: 'WiFi Connectivity', priceModifier: 15, compatible: ['small', 'medium', 'large'] },
    { id: 'bluetooth', name: 'Bluetooth', priceModifier: 10, compatible: ['small', 'medium', 'large'] },
    { id: 'gps', name: 'GPS Module', priceModifier: 20, compatible: ['medium', 'large'] },
    { id: 'battery', name: 'Extended Battery', priceModifier: 25, compatible: ['medium', 'large'] }
  ]
};

const selectedOptions = {
  color: 'red',
  size: 'medium',
  features: []
};

Use the directives and event handling techniques covered in this lecture to build an interactive UI for this configurator.

Key Takeaways

Additional Resources