·읽는데 13분

레이아웃 관련 공통 컴포넌트 개발하기

이번 시간에는 Header, Footer, Page와 같은 레이아웃 관련 공통 컴포넌트를 개발합니다. 이와 함께 언어 전환(Language Switcher), 테마 전환(Theme Switcher), 그리고 공통적으로 사용될 ButtonLink 컴포넌트를 만들어 레이아웃에 통합해 보겠습니다.


app.vue

<script setup lang="ts">
const head = useLocaleHead()
</script>

<template>
  <Html
    :lang="head.htmlAttrs?.lang"
    :dir="head.htmlAttrs?.dir"
  >
    <Head>
      <template
        v-for="link in head.link"
        :key="link.id"
      >
        <Link
          :id="link.id"
          :rel="link.rel"
          :href="link.href"
          :hreflang="link.hreflang"
        />
      </template>
      <template
        v-for="meta in head.meta"
        :key="meta.id"
      >
        <Meta
          :id="meta.id"
          :property="meta.property"
          :content="meta.content"
        />
      </template>
    </Head>
    <Body>
      <nuxt-loading-indicator />
      <the-header />
      <nuxt-page />
      <the-footer />
    </Body>
  </Html>
</template>
  • 34행: 페이지 간 탐색 시 상단에 로딩 상태를 표시하기 위한 컴포넌트입니다. 사용자는 이동 중 현재 진행 상황을 시각적으로 확인할 수 있습니다.
  • 35·37행: HeaderFooter는 사이트 레이아웃을 구성하는 필수 컴포넌트로, 재사용의 목적보다는 페이지 구조를 명확히 하는 것에 중점을 뒀습니다. 오류를 피하기 위해서 먼저 컴포넌트 파일을 생성한 후 코드를 작성하세요.

버튼 컴포넌트

헤더를 만들기 전에, 프로젝트 전체에서 공통으로 사용될 버튼 컴포넌트를 만들어보겠습니다.

<script setup lang="ts">
  import { twJoin, twMerge } from 'tailwind-merge'
  import ButtonStyles from '~/constant/ButtonStyle'
  import type { ButtonProps } from '~/types/Button'

  const props = withDefaults(defineProps<ButtonProps>(), {
    variant: 'solid',
    color: 'primary',
  })

  const resolveVariant = computed(() => {
    return twJoin(
        ButtonStyles[props.variant].base,
        ButtonStyles[props.variant][unref(props.color)],
        props.square ? 'p-1.5' : 'px-2.5 py-1.5 ',
    )
  })
</script>

<template>
  <button :class="twMerge(resolveVariant, props.class)">
    <slot />
  </button>
</template>

버튼을 개발하다 보면, 미묘한 차이가 있는 여러 디자인을 만들어야 하는 경우가 있습니다. 이로 인해 버튼 컴포넌트가 지나치게 비대해지는 것을 방지하고자 기본 스타일 프리셋만 관리하며, 필요시 부모 컴포넌트에서 CSS 클래스를 덮어씌울 수 있도록 설계했습니다.

링크 컴포넌트

외형은 버튼과 동일하게 구성했으나, 링크라는 특성으로 인한 차이점이 있습니다.

  1. 해당 링크가 활성화 상태라면 primary 색상으로 강제합니다.
  2. nuxt-linkactive class 속성 사용시 Tailwind CSS 클래스의 충돌이 일어났는데, 이 문제를 해결할 수 있도록 설계되었습니다.
  3. inactive class를 사용할 수 있도록 설계되었습니다.
<script setup lang="ts">
import { twJoin, twMerge } from 'tailwind-merge'
import type { RouterLinkProps } from '#vue-router'
import { NuxtLink, NuxtLinkLocale } from '#components'
import type { ButtonProps } from '~/types/Button'
import ButtonStyles from '~/constant/ButtonStyle'

defineOptions({
  inheritAttrs: false,
})

interface Props extends Partial<RouterLinkProps>, ButtonProps {
  inactiveClass?: string
  isLocalePath?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'ghost',
  color: 'gray',
  isLocalePath: true,
})

const resolveVariant = (variant: ButtonProps['variant'], color: ButtonProps['color'], isActive: boolean) => {
  const _variant = variant!
  const _color = isActive ? 'primary' : unref(color)!
  return twJoin(
    ButtonStyles[_variant].base,
    ButtonStyles[_variant][_color],
    props.square ? 'p-1.5' : 'px-2.5 py-1.5',
  )
}

const { isLocalePath: _isLocalePath, viewTransition: _viewTransition, variant: _variant, square: _square, color: _color, ...bindingProps } = props
</script>

<template>
  <component
    :is="isLocalePath? NuxtLinkLocale : NuxtLink"
    v-bind="bindingProps"
    v-slot="{ href, navigate, isActive, isExternal }"
    custom
  >
    <a
      v-bind="$attrs"
      :href="href"
      :class="twMerge(resolveVariant(variant, color, isActive), props.class, isActive ? activeClass : inactiveClass)"
      @click="(e) => !isExternal && navigate(e)"
    >
      <slot />
    </a>
  </component>
</template>

헤더 컴포넌트

헤더는 다음 네 가지 섹션으로 구성됩니다.

  • 로고 영역: 로고 및 홈 링크를 표시합니다.
  • GNB: 주요 페이지 링크를 제공합니다.
  • 유틸리티 바: 언어 전환, 테마 전환 버튼을 포함합니다.
  • 모바일 패널: 모바일 화면에서 GNB와 유틸리티 바를 표시합니다.
<script setup lang="ts">
  const links = [
    { label: 'Home', to: '/' },
    { label: 'Blog', to: '/blog' },
    { label: 'Archives', to: '/archives' },
  ]

  const isCollapsed = ref(false)

  useHead({
    bodyAttrs: {
      class: [() => isCollapsed.value ? 'overflow-hidden' : ''],
    },
  })

  const route = useRoute()
  watch(() => route.path, () => {
    isCollapsed.value = false
  })
</script>

<template>
  <header
      class="bg-white/75 dark:bg-slate-900/75 backdrop-blur relative top-0 z-50 [--header-height:7rem]"
      :class="isCollapsed ? 'bg-white dark:bg-black ' : 'bg-white/75 dark:bg-black/75'"
  >
    <div class="pl-6 pr-4 lg:px-8 mx-auto max-w-7xl h-[--header-height] flex items-center justify-between gap-3">
      <!-- logo section -->
      <div class="lg:flex-1 flex items-center gap-1.5">
        <nuxt-link-locale
            to="/"
            class="font-bold leading-none text-2xl dark:text-slate-100 flex items-center gap-3"
            aria-label="Go to home"
        >
          <icon
              name="besfir:logo"
              size="32"
          />

          Besfir
        </nuxt-link-locale>
      </div>

      <!-- gnb section -->
      <div class="items-center gap-x-3 hidden lg:flex">
        <div
            v-for="{ label, to } in links"
            :key="label"
            class="relative"
        >
          <app-link
              :to="to"
              class="text-base"
          >
            {{ label }}
          </app-link>
        </div>
      </div>

      <!-- utility bar section -->
      <div class="flex items-center justify-end lg:flex-1 gap-1.5">
        <the-lang-switcher class="hidden lg:flex" />
        <the-theme-switcher class=" hidden lg:flex" />
        <app-button
            class="lg:hidden"
            color="gray"
            variant="ghost"
            square
            :aria-label="isCollapsed ? 'Close aside' : 'Open aside'"
            @click="isCollapsed = !isCollapsed"
        >
          <icon
              v-if="isCollapsed"
              name="ph:x-bold"
          />
          <icon
              v-else
              name="ph:list-bold"
          />
        </app-button>
      </div>

      <!-- mobile panel section -->
      <div
          v-show="isCollapsed"
          class="fixed inset-x-0 top-[--header-height] h-[calc(100vh-var(--header-height))] bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-700 px-8 lg:hidden"
      >
        <div class="pt-6 pb-24 max-w-[288px] mx-auto">
          <app-link
              v-for="{ label, to } in links"
              :key="label"
              :to="to"
              class="py-3 flex border-b dark:border-slate-700 rounded-none"
          >
            {{ label }}
          </app-link>

          <div class="flex items-center justify-center mt-4">
            <the-lang-switcher />
          </div>

          <div class="text-xs py-2 pl-4 pr-3 bg-slate-100 dark:bg-slate-800 rounded-lg flex items-center justify-between mt-6">
            Color Mode <the-theme-switcher class="ml-auto" />
          </div>

          <app-sns-links class="mt-4" />
        </div>
      </div>
    </div>
  </header>
</template>

로고 이미지는 각자 준비한 것을 사용합니다. 저는 피그마 커뮤니티에 공유되어 있는 hand drawn illustration을 샘플로 사용했습니다.

SNS 링크

Header 컴포넌트(106행)과 뒤에서 만들 footer 컴포넌트에서 여러분의 SNS 링크들을 보여줄 수 있도록 설계했습니다. 재사용이 가능하도록 SNS 링크들을 컴포넌트로 만들어 보겠습니다.

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { twMerge } from 'tailwind-merge'

interface Props {
  class?: HTMLAttributes['class']
}
const props = defineProps<Props>()

const sns = [
  { icon: 'ph:instagram-logo', to: 'https://www.instagram.com/', label: 'go to instagram' },
  { icon: 'ph:github-logo', to: 'https://github.com/', label: 'go to github' },
  { icon: 'ph:linkedin-logo', to: 'https://www.linkedin.com/', label: 'go to linkedin' },
]
</script>

<template>
  <div :class="twMerge('flex items-center justify-center gap-x-1.5', props.class)">
    <app-link
      v-for="{ to, icon, label } in sns"
      :key="icon"
      :to="to"
      square
      rel="noopener noreferrer"
      target="_blank"
      :aria-label="label"
    >
      <icon
        :name="icon"
        aria-hidden="true"
      />
    </app-link>
  </div>
</template>

언어 스위치

lodash를 사용할 것이기 때문에 Nuxt lodash 모듈을 설치해줍니다.

bun i -D nuxt-lodash

웹 사이트의 현재 표시 언어를 전환할 수 있는 언어 스위치를 만들어 보겠습니다. 프래그먼트(해시) 값이 각 언어별 페이지마다 다르기 때문에, 언어 전환시 라우팅 오류가 발생할 수 있습니다. 이를 방지하기 위해 withoutFragment() 함수로 라우트 경로의 프래그먼트를 제거해줍니다.

<script setup lang="ts">
import { withoutFragment } from 'ufo'

const { locale, locales } = useI18n()
const localeItems = computed(() => {
  const [currentLocale, availableLocales] = usePartition(locales.value.map(({ code, name }) => ({ code, name })), ({ code }) => code === locale.value)
  return { currentLocale: currentLocale[0], availableLocales }
})
const switchLocalePath = useSwitchLocalePath()
</script>

<template>
  <div class="relative group">
    <app-button
      color="gray"
      variant="ghost"
      square
      aria-label="language switcher"
    >
      <icon name="ph:translate-duotone"/>
      <span class=" lg:hidden">{{ localeItems.currentLocale.name }}</span>
      <icon
        name="ph:caret-up-down"
        class="lg:hidden"
      />
    </app-button>

    <div class="hidden flex-col group-hover:flex absolute top-full right-0 shadow-lg p-3 border border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900">
      <span class="px-3 py-1.5 text-sm font-bold hidden lg:block select-none">{{ localeItems.currentLocale.name }}</span>
      <app-link
        v-for="{ code, name } in localeItems.availableLocales"
        :key="code"
        :to="withoutFragment(switchLocalePath(code))"
        :is-locale-path="false"
        class="px-3"
      >
        <span class="truncate text-sm">{{ name }}</span>
      </app-link>
    </div>
  </div>
</template>

테마 스위치

밝은 모드와 어두운 모드로 전환할 수 있는 테마 스위치를 만들어보겠습니다. 저는 시스템 기본 설정에 따라서 테마를 감지하는 System preference를 적용하지 않았지만, 필요하다면 Nuxt Color 모듈 문서를 참고하면 되겠습니다.

<template>
  <app-button
    :aria-label="`Switch to ${isDark ? 'light' : 'dark'} mode`"
    variant="ghost"
    :color="isDark ? 'primary' : 'gray'"
    square
    v-bind="$attrs"
    @click="isDark = !isDark"
  >
    <icon :name="isDark ? 'ph:moon-stars-duotone' : 'ph:sun-duotone'" />
  </app-button>
</template>

<script setup lang="ts">
const colorMode = useColorMode()

const isDark = computed({
  get() {
    return colorMode.value === 'dark'
  },
  set() {
    colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
  },
})
</script>

푸터 컴포넌트

푸터는 사이트 하단 정보를 제공하며, 주요 섹션은 다음과 같습니다:

  • 영역 구분선
  • 저작권 및 크레딧
  • 후원 링크
  • SNS 링크
<script setup lang="ts">
const links = [
  {
    label: 'Ko-fi',
    to: 'https://ko-fi.com/besfir/?hidefeed=true&widget=true&embed=true',
  }, {
    label: 'Buy me a coffee',
    to: 'https://buymeacoffee.com/5kni3hu',
  }, {
    label: '투네이션',
    to: 'https://toon.at/donate/besfir',
  },
]
</script>

<template>
  <div>
    <!-- divider section -->
    <div class="w-full h-px bg-slate-200 dark:bg-slate-800 flex items-center justify-center">
      <div class="bg-white dark:bg-slate-900 px-4">
        <icon
          name="ph:hand-heart-duotone"
          aria-hidden="true"
        />
      </div>
    </div>

    <footer class="relative text-slate-500 dark:text-slate-400">
      <div class="mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl py-8 lg:py-4 lg:flex lg:items-center lg:justify-between lg:gap-x-3">
        <!-- sns links section -->
        <app-sns-links class="lg:flex-1 lg:justify-end lg:order-3" />
        <!-- donation links section -->
        <div class="mt-3 lg:mt-0 lg:order-2 flex items-center justify-center">
          <div class="flex flex-col md:flex-row items-center justify-center gap-0.5 lg:gap-1">
            <app-link
              v-for="{ label, to } in links"
              :key="label"
              class="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 relative"
              :to="to"
              rel="noopener noreferrer"
              target="_blank"
            >
              {{ label }}
            </app-link>
          </div>
        </div>
        <!-- copyright & credits section -->
        <div class="flex flex-col items-center justify-center lg:items-start lg:flex-1 gap-x-1.5 mt-3 lg:mt-0 lg:order-1 text-xs gap-y-1">
          <span>© 2024-{{ new Date().getFullYear() }} besfir blog</span>
          <span>Powered by Nuxt.js, Cloudflare Pages</span>
        </div>
      </div>
    </footer>
  </div>
</template>

페이지 컴포넌트

각 페이지 컴포넌트에서 body 역할을 담당할 컴포넌트를 만들어 보겠습니다. 앞서 만든 버튼이나 링크 컴포넌트와 같이 부모 컴포넌트에서 스타일을 커스터마이징 할 수 있도록 설계되었습니다.

<script setup lang="ts">
import { twJoin, twMerge } from 'tailwind-merge'
import type { HTMLAttributes } from 'vue'

defineOptions({
  inheritAttrs: false,
})
interface Props {
  class?: HTMLAttributes['class']
  innerClass?: HTMLAttributes['class']
  isProse?: boolean
}
const props = defineProps<Props>()

const resolveClass = computed(() => {
  return twMerge(
    twJoin('px-6 md:px-12 pb-10 min-h-[calc(100vh-var(--header-height))]', props.isProse && 'prose prose-slate dark:prose-invert max-w-none md:prose-lg'),
    props.class,
  )
})
</script>

<template>
  <div :class="resolveClass">
    <div :class="twMerge('mx-auto max-w-screen-lg', innerClass)">
      <slot />
    </div>
  </div>
</template>

여기까지의 프로젝트 구조

소스 코드의 파일 경로는 이 섹션을 참고해주세요.

.
├── README.md
├── app.vue
├── assets
│   ├── css
│   │   └── main.pcss
│   └── icons
│       └── logo.svg
├── bun.lockb
├── components
│   ├── App
│   │   ├── Button.vue
│   │   ├── Link.vue
│   │   └── SnsLinks.vue
│   └── The
│       ├── Footer.vue
│       ├── Header.vue
│       ├── LangSwitcher.vue
│       ├── Page.vue
│       └── ThemeSwitcher.vue
├── constant
│   └── ButtonStyle.ts
├── eslint.config.mjs
├── i18n
│   ├── i18n.config.ts
│   └── locales
│       ├── en.yaml
│       └── ko.yaml
├── nuxt.config.ts
├── package.json
├── pages
│   ├── archives.vue
│   ├── blog.vue
│   └── index.vue
├── public
│   ├── favicon.ico
│   └── robots.txt
├── server
│   └── tsconfig.json
├── tailwind.config.ts
├── tsconfig.json
└── types
    └── Button.ts