DEV Community

yuelinghuashu
yuelinghuashu

Posted on

Vue 3 Complex Component Development: API Design for Select and Pagination

From data format adaptation to route synchronization, diving into the design principles and logic reuse of complex components

This article covers: flexible data format support (object arrays, string arrays, number arrays), custom field mapping (labelKey/valueKey), route state synchronization (pagination + filters bound to URL), keyboard navigation (without interfering with input fields), logic extraction and reuse (composables), and industrial-grade details: type backtracking, defensive programming, and extreme edge case handling.


I. Introduction

If writing the Button component is about enjoying the "paint-like beauty" of CSS variables, then writing Select and Pagination is about wrestling in the "quagmire" of native HTML legacy baggage. Simple components are one-way data consumers, while complex components are data adapters (supporting multiple formats) and state synchronizers (coordinating with routing and keyboard events).

This article uses Select and Pagination as examples to demonstrate the development approach for complex components, covering:

  • Flexible data format support (object arrays, string arrays, number arrays)
  • Custom field mapping (labelKey / valueKey)
  • Route state synchronization (page numbers and filters bound to URL)
  • Accessibility features (keyboard navigation, mobile gestures)
  • Logic extraction and reuse (composables)
  • Industrial-grade details: type backtracking, defensive programming, event pollution prevention, extreme edge case handling

II. Select Dropdown: The Data Adapter

The Select component needs to receive a list of options and allow the user to select one. Real-world APIs may return object arrays, string arrays, or even number arrays, so the component must have robust data adaptation capabilities.

2.1 Requirements Analysis

  • Support object arrays { label, value } (default)
  • Support custom field names (labelKey / valueKey)
  • Support string arrays ['Option A', 'Option B']
  • Support number arrays [1, 2, 3]
  • Provide a placeholder (non-selectable default option)
  • Support disabled options (disabled: true)
  • Support error state, sizing, disabled, and other common attributes
  • Must solve the native <select> type trap (always returns strings)
  • Support native <form> submission (via name attribute)

2.2 API Design and Type Preservation

To avoid type pollution from any, use union types to narrow the scope.

type SelectOption = string | number | Record<string, any>

interface Props {
  modelValue?: string | number
  options?: SelectOption[]      // Union type, rejects bare any
  labelKey?: string             // Default 'label'
  valueKey?: string             // Default 'value'
  placeholder?: string
  name?: string                 // Supports native form submission
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  error?: boolean
}
Enter fullscreen mode Exit fullscreen mode

2.3 Internal Implementation: Defensive Programming + Type Backtracking

<template>
  <select
    class="mg-select"
    :class="[`mg-select-${size}`, { 'mg-select-error': error }]"
    :value="modelValue"
    :disabled="disabled"
    :name="name"
    v-bind="$attrs"
    @change="handleChange"
  >
    <option v-if="placeholder" value="" disabled hidden>
      {{ placeholder }}
    </option>
    <option
      v-for="item in options || []"
      :key="getValue(item)"
      :value="getValue(item)"
      :disabled="item.disabled"
    >
      {{ getLabel(item) }}
    </option>
  </select>
</template>

<script setup lang="ts">
// ... type definitions

const getLabel = (item: any): string => {
  if (item === null || item === undefined) return ''
  if (typeof item !== 'object') return String(item)
  const label = item[props.labelKey]
  return label !== undefined ? String(label) : String(item)
}

const getValue = (item: any): any => {
  if (item === null || item === undefined) return undefined
  if (typeof item !== 'object') return item
  const val = item[props.valueKey]
  return val !== undefined ? val : item
}

// πŸ”₯ Core: Solving the fatal issue where native select always returns strings
const handleChange = (event: Event) => {
  const target = event.target as HTMLSelectElement
  const rawValue = target.value

  // Attempt to find the original type (number or object value) from the original options
  const originalItem = (props.options || []).find(item => String(getValue(item)) === rawValue)
  const finalValue = originalItem !== undefined ? getValue(originalItem) : rawValue

  emit('update:modelValue', finalValue)
  emit('change', finalValue)
}
</script>
Enter fullscreen mode Exit fullscreen mode

Defensive Programming Note: options || [] ensures that even if undefined is passed in externally or loading is delayed, the component won't break. The type backtracking logic ensures that number values bound via v-model won't accidentally become strings. Explicit support for the name attribute allows <select> to participate in native form submission.

2.4 Usage Examples

<!-- Object array (default fields) -->
<Select v-model="category" :options="categories" name="category" />

<!-- Custom field names -->
<Select v-model="category" :options="cats" label-key="name" value-key="id" />

<!-- String array (type preserved automatically) -->
<Select v-model="color" :options="['Red', 'Green', 'Blue']" />

<!-- Number array -->
<Select v-model="num" :options="[10, 20, 30]" />

<!-- Disabled options -->
<Select v-model="status" :options="statusOptions" />
Enter fullscreen mode Exit fullscreen mode

III. Pagination Component: The State Synchronizer

The Pagination component needs to work with the current page number and items per page, providing previous/next buttons and supporting direct input for page jumps. In Nuxt projects, the page number often needs to be synchronized with the URL, and keyboard left/right arrow navigation must be supported without interfering with input fields.

3.1 Requirements Analysis

  • Display current page / total pages
  • Previous/next buttons (disabled at boundaries)
  • Clicking the current page number turns it into an input field for direct page jumps
  • Synchronize with route query (update URL when page changes)
  • Support keyboard left/right arrow navigation (must not interfere with input fields)
  • Extreme edge case defense (empty input, out-of-range values, etc.)
  • Size adaptation

3.2 API Design

interface Props {
  currentPage: number       // v-model:current-page
  totalPages: number
  size?: 'sm' | 'md' | 'lg'
}
Enter fullscreen mode Exit fullscreen mode

3.3 Core Implementation (with Extreme Edge Case Defense)

<template>
  <nav class="mg-pagination" :class="`mg-pagination-${size}`">
    <button class="mg-pagination-btn" :disabled="currentPage === 1" @click="goPrev">
      Previous
    </button>

    <span v-if="!editing" class="mg-pagination-current" @click="startEdit">
      {{ currentPage }}
    </span>
    <input
      v-else
      ref="inputRef"
      v-model="inputPage"
      type="number"
      class="mg-pagination-input"
      :min="1"
      :max="totalPages"
      @blur="commit"
      @keyup.enter="commit"
    />

    <span class="mg-pagination-sep">/</span>
    <span class="mg-pagination-total">{{ totalPages }}</span>

    <button class="mg-pagination-btn" :disabled="currentPage === totalPages" @click="goNext">
      Next
    </button>
  </nav>
</template>

<script setup lang="ts">
import { ref, nextTick } from 'vue'

const props = defineProps<{ currentPage: number; totalPages: number; size?: string }>()
const emit = defineEmits<{ 'update:currentPage': [page: number] }>()

const editing = ref(false)
const inputPage = ref(props.currentPage)
const inputRef = ref<HTMLInputElement>()

const goPrev = () => { if (props.currentPage > 1) emit('update:currentPage', props.currentPage - 1) }
const goNext = () => { if (props.currentPage < props.totalPages) emit('update:currentPage', props.currentPage + 1) }

const startEdit = () => {
  editing.value = true
  inputPage.value = props.currentPage
  nextTick(() => inputRef.value?.focus())
}

const commit = () => {
  editing.value = false
  const raw = inputPage.value
  const pageNum = parseInt(String(raw), 10)

  // Invalid input: abandon without updating the page
  if (isNaN(pageNum)) return

  let page = Math.min(Math.max(pageNum, 1), props.totalPages)
  if (page !== props.currentPage) {
    emit('update:currentPage', page)
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

3.4 Keyboard Navigation Logic (Composable)

To keep the component clean, extract keyboard events into useKeyboardPagination, ensuring they don't interfere with input fields.

// composables/useKeyboardPagination.ts
import { onMounted, onUnmounted } from 'vue'

export function useKeyboardPagination(onPrev: () => void, onNext: () => void) {
  const handleKeyDown = (e: KeyboardEvent) => {
    // πŸ”₯ Critical: Don't trigger pagination if focus is in an input or textarea
    const target = e.target as HTMLElement
    if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return

    if (e.key === 'ArrowLeft') onPrev()
    if (e.key === 'ArrowRight') onNext()
  }

  onMounted(() => window.addEventListener('keydown', handleKeyDown))
  onUnmounted(() => window.removeEventListener('keydown', handleKeyDown))
}
Enter fullscreen mode Exit fullscreen mode

3.5 Route Synchronization and All-in-One Composable

To demonstrate the elegance of "one-click assembly", combine page route synchronization, keyboard events, and navigation methods into a single all-in-one composable.

// composables/useMoongatePagination.ts
import { ref, type Ref } from 'vue'
import { useRouteQueryNumber } from './useRouteQuery'
import { useKeyboardPagination } from './useKeyboardPagination'

export function useMoongatePagination(totalPages: Ref<number>) {
  const page = useRouteQueryNumber('page', { defaultValue: 1 })

  const goPrev = () => { if (page.value > 1) page.value-- }
  const goNext = () => { if (page.value < totalPages.value) page.value++ }

  // Keyboard events are assembled internally
  useKeyboardPagination(goPrev, goNext)

  return { page, goPrev, goNext }
}
Enter fullscreen mode Exit fullscreen mode

Usage in a business page:

<script setup>
const totalPages = ref(10)
const { page, goPrev, goNext } = useMoongatePagination(totalPages)
// No need to handle keyboard events separately β€” everything is automatic
</script>

<template>
  <Pagination v-model:current-page="page" :total-pages="totalPages" />
  <!-- You can also use custom buttons linked to the pagination state -->
  <button @click="goPrev">Previous</button>
  <button @click="goNext">Next</button>
</template>
Enter fullscreen mode Exit fullscreen mode

IV. Data and State Flow in Complex Components

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    Complex Component Data Flow               β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
β”‚  β”‚ External    β”‚ -> β”‚ Data        β”‚ -> β”‚ Internal    β”‚     β”‚
β”‚  β”‚ Data        β”‚    β”‚ Adapter     β”‚    β”‚ State       β”‚     β”‚
β”‚  β”‚ (options)   β”‚    β”‚ (getLabel/  β”‚    β”‚ (selected)  β”‚     β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚  getValue)  β”‚    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜     β”‚
β”‚                     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜           β”‚            β”‚
β”‚                                              ↓            β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
β”‚  β”‚ Global      β”‚ <- β”‚ State       β”‚ <- β”‚ User        β”‚     β”‚
β”‚  β”‚ Environment β”‚    β”‚ Synchronizerβ”‚    β”‚ Interaction β”‚     β”‚
β”‚  β”‚ (route/key) β”‚    β”‚ (watch/     β”‚    β”‚ (click/     β”‚     β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚  event)     β”‚    β”‚  keyboard)  β”‚     β”‚
β”‚                     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

V. Testing Strategies for Complex Components

Concern Simple Components (Button) Complex Components (Select / Pagination)
Testing Strategy Snapshot testing, simple click event triggering State snapshot combinations, edge case testing (page 0 / exceeding total pages), DOM focus and keyboard simulation testing, type backtracking robustness testing

VI. Summary

Concern Simple Components (Button) Complex Components (Select / Pagination)
Number of Props Few (5-8) Many (10+)
Data Format Fixed (string) Flexible (supports multiple arrays, configurable fields, type preservation)
State Management No internal state May have internal edit state, input visibility toggling, extreme edge case defense
External Dependencies None Routing, keyboard events, gestures
Logic Reuse Not needed Strongly recommended to extract composables, can integrate into all-in-one functions
Defensive Programming Low High (needs to handle undefined data, type backtracking, empty input recovery)
Native Form Integration Auto-transparent name Explicit name attribute support, can participate in <form> submission
Testing Strategy Snapshot, event triggering State combinations, edge cases, keyboard simulation, type backtracking

An excellent complex component acts like a vacuum cleaner internally, accommodating all kinds of quirky backend data formats (via key mapping and type backtracking), while externally interacting with the global environment (routes, window keyboard events) with gentlemanly restraint. High cohesion, low coupling β€” this principle is fully embodied in both types of components.


This article is part of the **Vue 3 Component Library Development Guide* series.*

The original Chinese version is available on my blog: moongate.top.

Try the component library on npm: moongate-vue

Β© 2026 yuelinghuashu. This work is licensed under CC BY-NC 4.0.

Top comments (0)