레이아웃 관련 공통 컴포넌트 개발하기
이번 시간에는 Header
, Footer
, Page
와 같은 레이아웃 관련 공통 컴포넌트를 개발합니다. 이와 함께 언어 전환(Language Switcher
), 테마 전환(Theme Switcher
), 그리고 공통적으로 사용될 Button
과 Link
컴포넌트를 만들어 레이아웃에 통합해 보겠습니다.
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행:
Header
와Footer
는 사이트 레이아웃을 구성하는 필수 컴포넌트로, 재사용의 목적보다는 페이지 구조를 명확히 하는 것에 중점을 뒀습니다. 오류를 피하기 위해서 먼저 컴포넌트 파일을 생성한 후 코드를 작성하세요.
버튼 컴포넌트
헤더를 만들기 전에, 프로젝트 전체에서 공통으로 사용될 버튼 컴포넌트를 만들어보겠습니다.
<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 클래스를 덮어씌울 수 있도록 설계했습니다.
링크 컴포넌트
외형은 버튼과 동일하게 구성했으나, 링크라는 특성으로 인한 차이점이 있습니다.
- 해당 링크가 활성화 상태라면
primary
색상으로 강제합니다. nuxt-link
의active class
속성 사용시 Tailwind CSS 클래스의 충돌이 일어났는데, 이 문제를 해결할 수 있도록 설계되었습니다.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