Files
pacnpal 1adba1b804 lol
2026-01-02 07:58:58 -05:00

6.6 KiB

description
description
Create a new Vue component following ThrillWiki patterns

New Component Workflow

Create a new Vue component following ThrillWiki's design system and conventions.

Information Gathering

  1. Component Name: PascalCase (e.g., ParkCard, RatingDisplay)
  2. Category:
    • ui/ - Base components (Button, Card, Input)
    • entity/ - Domain-specific (ParkCard, RideCard)
    • forms/ - Form components
    • specialty/ - Complex/unique components
  3. Props: What data does it receive?
  4. Emits: What events does it emit?
  5. State: Does it have internal state?
  6. Variants: Does it need multiple variants/sizes?

Component Template

Base Component Structure

Location: frontend/components/[category]/ComponentName.vue

<script setup lang="ts">
// 1. Import types
import type { ComponentProps } from '~/types'

// 2. Define props with TypeScript
const props = withDefaults(defineProps<{
  // Required props
  title: string
  // Optional props with defaults
  variant?: 'default' | 'compact' | 'expanded'
  disabled?: boolean
}>(), {
  variant: 'default',
  disabled: false,
})

// 3. Define emits
const emit = defineEmits<{
  (e: 'click'): void
  (e: 'select', value: string): void
}>()

// 4. Use composables
const { formatDistance } = useUnits()

// 5. Internal state
const isOpen = ref(false)

// 6. Computed properties
const computedClass = computed(() => ({
  'opacity-50 pointer-events-none': props.disabled,
  'p-4': props.variant === 'default',
  'p-2': props.variant === 'compact',
}))

// 7. Methods
function handleClick() {
  if (!props.disabled) {
    emit('click')
  }
}
</script>

<template>
  <div :class="computedClass" @click="handleClick">
    <!-- Component content -->
    <slot />
  </div>
</template>

Entity Card Component

For ParkCard, RideCard, etc.:

<script setup lang="ts">
import type { Park } from '~/types'
import { MapPin, Star } from 'lucide-vue-next'

const props = defineProps<{
  park: Park
  variant?: 'default' | 'compact'
}>()

const statusVariant = computed(() => {
  switch (props.park.status) {
    case 'operating': return 'success'
    case 'closed': return 'destructive'
    case 'construction': return 'warning'
    default: return 'default'
  }
})
</script>

<template>
  <NuxtLink :to="`/parks/${park.slug}`">
    <Card interactive class="overflow-hidden">
      <!-- Image -->
      <div class="aspect-video relative">
        <NuxtImg
          :src="park.image || '/placeholder-park.jpg'"
          :alt="park.name"
          class="object-cover w-full h-full"
          loading="lazy"
        />
        <Badge 
          :variant="statusVariant" 
          class="absolute top-2 right-2"
        >
          {{ park.status }}
        </Badge>
      </div>
      
      <!-- Content -->
      <div class="p-4">
        <h3 class="font-semibold text-lg line-clamp-1">
          {{ park.name }}
        </h3>
        
        <p class="text-sm text-muted-foreground flex items-center gap-1 mt-1">
          <MapPin class="w-4 h-4" />
          {{ park.city }}, {{ park.country }}
        </p>
        
        <div class="flex items-center justify-between mt-3">
          <span class="text-sm text-muted-foreground">
            🎢 {{ park.rideCount }} rides
          </span>
          <RatingDisplay 
            v-if="park.averageRating" 
            :rating="park.averageRating" 
            size="sm"
          />
        </div>
      </div>
    </Card>
  </NuxtLink>
</template>

Skeleton Loading Component

Every entity card should have a matching skeleton:

<script setup lang="ts">
defineProps<{
  variant?: 'default' | 'compact'
}>()
</script>

<template>
  <Card>
    <Skeleton class="aspect-video rounded-t-lg" />
    <div class="p-4 space-y-2">
      <Skeleton class="h-5 w-3/4" />
      <Skeleton class="h-4 w-1/2" />
      <div class="flex justify-between mt-3">
        <Skeleton class="h-4 w-20" />
        <Skeleton class="h-4 w-16" />
      </div>
    </div>
  </Card>
</template>

Variant Pattern (using CVA)

For components with many variants, use class-variance-authority:

<script setup lang="ts">
import { cva, type VariantProps } from 'class-variance-authority'

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        outline: 'border border-input bg-background hover:bg-accent',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-8 px-3 text-sm',
        lg: 'h-12 px-6 text-lg',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
)

type Props = VariantProps<typeof buttonVariants> & {
  loading?: boolean
  disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  loading: false,
  disabled: false,
})
</script>

<template>
  <button
    :class="buttonVariants({ variant, size })"
    :disabled="disabled || loading"
  >
    <Loader2 v-if="loading" class="w-4 h-4 mr-2 animate-spin" />
    <slot />
  </button>
</template>

Composable Integration

If component needs shared logic, create a composable:

// composables/useUnitDisplay.ts
export function useUnitDisplay() {
  const { preferredUnits } = useUserPreferences()
  
  function formatSpeed(kmh: number): string {
    if (preferredUnits.value === 'imperial') {
      return `${Math.round(kmh * 0.621371)} mph`
    }
    return `${kmh} km/h`
  }
  
  function formatHeight(meters: number): string {
    if (preferredUnits.value === 'imperial') {
      return `${Math.round(meters * 3.28084)} ft`
    }
    return `${meters} m`
  }
  
  return { formatSpeed, formatHeight }
}

Checklist

After creating the component:

  • TypeScript props are properly defined
  • Component follows design system (colors, spacing, typography)
  • Responsive on all screen sizes
  • Handles loading state (if applicable)
  • Handles empty state (if applicable)
  • Accessible (ARIA labels, keyboard nav)
  • Has matching skeleton component (for async data)
  • Works in dark mode

Output

Report what was created:

Created: frontend/components/[category]/ComponentName.vue
Props: [list of props]
Emits: [list of events]
Related: [any composables or sub-components]