Svelte Component 라이브러리 - 5일차
포스트
취소

Svelte Component 라이브러리 - 5일차

원하는 UI 구성을 위해 유틸리티 CSS 라이브러리인 TailwindCSS 와 Skeleton 를 공부합니다. 웹프레임워크로 SveltKit 을 사용하고 bun 런타임 위에서 실행합니다.

0. 개요

  • Bun 1.0.10 + SvelteKit 1.20.4
  • TailwindCSS 3.3.5
    • skeleton 2.5.0 (tw-plugin 0.2.4)
    • vite-plugin-tailwind-purgecss 0.1.3
  • Etc
    • heroicons
    • fontawesome-free
    • purgecss

1. 프로젝트 생성

1) SvelteKit 프로젝트 생성

1
2
3
4
5
6
7
8
bun create svelte@latest bun-skeleton-app
  # - Skeleton project
  # - Javascript with JSDoc

cd bun-skeleton-app
bun install

bun run dev

2) TailwindCSS 및 skeleton 설정

  1. skeleton, tw-plugin 설치
  2. tailwindcss, postcss 설치 (typography, forms 추가)
  3. heroicons 설치 (MIT 라이센스), fontawesome-free 설치 (무료)
  4. postcss.config.cjs 추가 (cjs 확장자)
  5. tailwind.config.js 에 skeleton 설정
  6. app.postcss 에 directives 와 noto 폰트 추가
  7. +layout.svelte 에 전역 css 추가
  8. +page.svelte 에 데모 코드를 넣어 작동 확인
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
bun add -d @skeletonlabs/skeleton @skeletonlabs/tw-plugin
bun add -d tailwindcss postcss autoprefixer
bun add -d @tailwindcss/typography @tailwindcss/forms
bun add -d svelte-hero-icons
bun add @fortawesome/fontawesome-free

bunx tailwindcss init

# postcss 는 CommonJS 확장자를 필요로 한다!
cat <<EOF > postcss.config.cjs 
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  }
}
EOF

# skeleton, fonts, 기본 theme 설정
cat <<EOF > tailwind.config.js
import { skeleton } from '@skeletonlabs/tw-plugin';
import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography';

const defaultTheme = require('tailwindcss/defaultTheme');

/** @type {import('tailwindcss').Config} */
export default {
  darkMode: 'class',
  content: [
    './src/**/*.{html,js,svelte,ts}',
    './node_modules/@skeletonlabs/skeleton/**/*.{html,js,svelte,ts}',
  ],
  theme: {
    fontFamily: {
      sans: ['"Noto Sans KR"', ...defaultTheme.fontFamily.sans],
      serif: ['"Noto Serif KR"', ...defaultTheme.fontFamily.serif],
      mono: ['D2Coding', ...defaultTheme.fontFamily.mono],
    },      
  },
  plugins: [
    forms,
    typography,  
    skeleton({
      themes: {
        preset: [
          {
            name: 'skeleton',
            enhancements: true,
          },
        ],
      },
    }),
  ]
}
EOF

# hover 빼고 skeleton theme 기본 적용 (Mac 에서는 첫번째 인자 ''가 필요하다)
sed -i '' 's/data-sveltekit-preload-data="hover"/data-theme="skeleton"/' src/app.html

# 전역 css 에 directives 와 noto 폰트 설정
cat <<EOF > src/app.postcss
/* fonts: Noto Color Emoji, Noto Sans KR, Noto Serif KR */
@import url('https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Noto+Sans+KR:wght@300;400;500;700&family=Noto+Serif+KR:wght@400;700&display=swap');
@import url("//cdn.jsdelivr.net/gh/wan2land/d2coding/d2coding-ligature-full.css");

@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind variants;

html, body {
  @apply h-full overflow-hidden;
}
EOF

cat <<EOF > src/routes/+layout.svelte
<script lang="ts">
  import '@fortawesome/fontawesome-free/css/all.min.css';
  import '../app.postcss';
</script>

<slot />
EOF

cat <<EOF > src/routes/+page.svelte
<script>
  import { Icon, Radio } from 'svelte-hero-icons';
</script>

<div class="container mx-auto p-8 space-y-8">
  <h1 class="h1">안녕, Skeleton!</h1>
  <button type="button" class="btn variant-filled">
    <span><Icon src={Radio} size="1rem" /></span>
    <span>Button</span>
  </button>
</div>
EOF

bun run dev

fontawesome-freepurgecss 설치

  • fontawesome-free 아이콘 (무료)
  • tailwindcss 최적화를 위한 vite 전용 purgecss 플러그인 설치
1
2
3
4
5
6
7
8
9
10
11
12
13
14
bun add -d vite-plugin-tailwind-purgecss

cat <<EOF > vite.config.ts
import { purgeCss } from 'vite-plugin-tailwind-purgecss';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [sveltekit(), purgeCss()],
  ssr: {
    noExternal: ['svelte-hero-icons'],
  },
});
EOF

svelte 에서 a11y warning 비활성화 시키기

a11y 의 좋은 목적은 알겠지만, 신경 쓰이는 경우가 많아 disable 시키고 싶었다.

vscode 의 settings.json 에서 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
"svelte.plugin.svelte.compilerWarnings": {
    "a11y-aria-attributes": "ignore",
    "a11y-incorrect-aria-attribute-type": "ignore",
    "a11y-unknown-aria-attribute": "ignore",
    "a11y-hidden": "ignore",
    "a11y-misplaced-role": "ignore",
    "a11y-unknown-role": "ignore",
    "a11y-no-abstract-role": "ignore",
    "a11y-no-redundant-roles": "ignore",
    "a11y-role-has-required-aria-props": "ignore",
    "a11y-accesskey": "ignore",
    "a11y-autofocus": "ignore",
    "a11y-misplaced-scope": "ignore",
    "a11y-positive-tabindex": "ignore",
    "a11y-invalid-attribute": "ignore",
    "a11y-missing-attribute": "ignore",
    "a11y-img-redundant-alt": "ignore",
    "a11y-label-has-associated-control": "ignore",
    "a11y-media-has-caption": "ignore",
    "a11y-distracting-elements": "ignore",
    "a11y-structure": "ignore",
    "a11y-mouse-events-have-key-events": "ignore",
    "a11y-missing-content": "ignore",
    "a11y-no-static-element-interactions":"ignore"
}

sveltekit.config.js 에서 컴파일 옵션 설정

1
2
3
4
5
6
7
8
9
10
const config = {
  preprocess: vitePreprocess(),
  onwarn: (warning, handler) => {
    if (warning.code.startsWith('a11y-')) {
      return;
    }
    handler(warning);
  },
  // ...
}

2. Skeleton.dev/blog 클론 코딩

소스 코드 : 깃허브 - skeletonlabs/skeleton

SvelteKit 프로젝트 구조

  • src : html, global css, hooks
    • lib
      • components : 컴포넌트 파일들
      • server : 서버에서만 실행되는 lib 파일들
    • routes : root 경로
      • blog : skeleton 의 블로그 리스트
        • [slug] : 블로그의 특정 포스트
      • docs : skeleton 의 설명서

Template Component 방식

동일한 포맷의 페이지를 템플릿 컴포넌트로 만들어 놓고 속성값만 바꿔서 재사용한다.

skeleton docs 설명에 쓰이는 페이지 템플릿

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- DocsShell.svelte -->
<script>
  export let settings;   // @type { DocsShellSettings }
  const pageData = {     // @type { DocsShellSettings }
    // Define defaults first
    ...docShellDefaults,
    // Local Overrides
    ...{ docsPath: $page.url.pathname },
    // Prop Settings Values
    ...settings
  };  
</script>

<LayoutPage class="doc-shell {classesBase}" tocKey={tabPanel}>
  <!-- Header -->
  <Header {pageData} />
  <!-- Panels -->
  <div id="panels" class={classesPanels}>
  </div>
</LayoutPage>    

특정 docs 페이지

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script>
  import DocsShell from '$lib/layouts/DocsShell/DocsShell.svelte';
  // DocsShellSettings
  const settings = {
    feature: DocsFeature.Component,
    name: 'Table of Contents',
    description: 'Allows you to quickly navigate the hierarchy of headings for the current page.',
    imports: ['TableOfContents', 'tocCrawler'],
    source: 'packages/skeleton/src/lib/utilities/TableOfContents',
    components: [{ sveld: sveldTableOfContents }]
  };  
</script>

<DocsShell {settings}>
  <svelte:fragment slot="sandbox">
  </svelte:fragment>
  <svelte:fragment slot="usage">
  </svelte:fragment>
</DocsShell>

Blog 리스트

  • page[.server].ts 로부터 Blog 리스트 데이터를 받아와서
  • section > a > article 안에 이미지와 제목, 본문발췌를 출력
    • Blog 포스트는 a 태그로 감싸서 출력
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<script lang="ts">
  export let data: PageData;
</script>

<!-- Blog List -->
<div class="page-container-wide page-padding">
  <header class="flex justify-between items-center">
    <div class="space-y-4">
      <h2 class="h2">The Skeleton Blog</h2>
      <p>Keep up with the latest news, tutorials, and releases for Skeleton.</p>
    </div>
  </header>
  <hr />
  <section class="blog-list space-y-8">
    {#each data.posts as post}
      <a
        class="block hover:card hover:variant-soft p-4 rounded-container-token"
        href="/blog/{post.slug}"
        data-sveltekit-preload-data="hover"
      >
        <article
          class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-4 lg:gap-8"
        >
          <!-- Featured Image -->
          {#if post.feature_image}
            <img
              class="bg-black/50 w-full lg:max-w-sm aspect-video rounded-container-token shadow-xl bg-cover bg-center"
              src={post.feature_image}
              alt="thumbnail"
            />
          {/if}
          <!-- Content -->
          <div class="space-y-4">
            <h2 class="h2">{post.title}</h2>
            <p>{post.excerpt}</p>
            <div class="flex items-center space-x-4">
              {#each post.tags as tag}
                <span class="text-xs font-bold opacity-50 capitalize"
                  >{tag.slug}</span
                >
              {/each}
            </div>
            <button class="btn variant-ghost-surface"
              >Read Article &rarr;</button
            >
          </div>
        </article>
      </a>
    {/each}
  </section>
  <footer>
    <!-- 생략 -->
  </footer>
</div>  

css 살펴보기

  • space-y-4 : flex 아이템 간의 세로 간격
  • items-center : flex 또는 grid 에서 보조축에 대한 중간 정렬
  • mx-auto : container 를 중앙에 위치시킨다
  • w-full : flex 아래에서 사용하며, 너비 전체를 사용
    • w-auto : 기존에 설정된 너비 설정을 무효화 (ex: md:w-auto)
1
2
3
4
5
6
.page-container-wide {
  @apply w-full max-w-7xl mx-auto space-y-10;
}
.page-padding {
  @apply p-4 md:p-10;
}

Blog 리스트의 Pagination 과 Form Actions

소스 코드에는 client 스크립트로 처리되던 것을 server 스크립트로 변경해보았다.

Pagination 제어부

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// +page.server.ts
import { getBlogList } from '$lib/server/blog-service';
import type { PageServerLoad, Actions } from './$types';

export const load: PageServerLoad = async ({ fetch }) => {
  return getBlogList(fetch, 1);  // 첫 페이지
};

export const actions = {
  prevPage: async ({ request, fetch }) => {
    const data = await request.formData();
    const page = Number(data.get('page'));
    console.log(`Action: prevPage=${page - 1}`);
    return getBlogList(fetch, page - 1);
  },
  nextPage: async ({ request, fetch }) => {
    const data = await request.formData();
    const page = Number(data.get('page'));
    console.log(`Action: nextPage=${page + 1}`);
    return getBlogList(fetch, page + 1);
  },
} satisfies Actions;

Pagination 출력부

  • PageData 로 초기 데이터를 받아오고
    • data.meta.pagination 에 현재 페이지 번호와 총 페이지 번호가 있음
  • ActionData 로 새로운 페이지 번호에 대한 블로그 리스트 데이터를 받아온다.
    • 갱신된 form 으로 data 도 갱신 (effect 처리)
  • 이전, 이후 페이지에 대한 제한은 button 비활성화로 처리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!-- +page.svelte -->
<script lang="ts">
  import type { PageData, ActionData } from './$types';
  import { enhance } from '$app/forms';
  export let data: PageData;
  export let form: ActionData;

  $: if (form) {
    data = form;
  }
</script>

<!-- Pagination -->
<footer class="flex justify-between items-center space-x-4">
  <div>
    <small class="opacity-50"
      >Page {data.meta.pagination.page} of {data.meta.pagination.pages}</small
    >
  </div>
  <div class="flex items-center space-x-4">
    <form method="POST" action="?/prevPage" use:enhance>
      <input type="hidden" name="page" value={data.meta.pagination.page} />
      <button
        type="submit"
        class="btn-icon variant-filled"
        disabled={!data.meta.pagination.prev}>&larr;</button
      >
    </form> <!-- 이전 페이지 버튼 폼 -->
    <form method="POST" action="?/nextPage" use:enhance>
      <input type="hidden" name="page" value={data.meta.pagination.page} />
      <button
        type="submit"
        class="btn variant-filled"
        disabled={!data.meta.pagination.next}>Next &rarr;</button
      >
    </form> <!-- 다음 페이지 버튼 폼 -->
  </div>
</footer>

form action 동작중 loading 처리

  • use:enhance={submitFunction} 으로 action 호출시 hook 함수 연결
  • aria-busy={loading} 으로 관련 button 을 비활성화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<!-- +page.svelte -->
<script lang="ts">
  import type { SubmitFunction } from './$types';
  import { enhance } from '$app/forms';
  // 생략...

  let loading = false;
  const onChangePage: SubmitFunction = () => {
    loading = true;
    console.log('onChangePage: loading...');
    return async ({ update }) => {
      loading = false;
      await update();
    };
  };  
</script>

<!-- 생략 -->    
<div class="flex items-center space-x-4">
  <form method="POST" action="?/prevPage" use:enhance={onChangePage}>
    <button
      aria-busy={loading}        
      type="submit"
      class="btn-icon variant-filled"
      disabled={!data.meta.pagination.prev}>&larr;</button
    >
  </form>
  <form method="POST" action="?/nextPage" use:enhance={onChangePage}>
    <button
      aria-busy={loading}
      type="submit"
      class="btn variant-filled"
      disabled={!data.meta.pagination.next}>Next &rarr;</button
    >
  </form>
</div>

Blog 콘텐츠 페이지

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<script lang="ts">
  import hljs from 'highlight.js/lib/core';
  import { onMount } from 'svelte';

  import type { PageData } from './$types';  
  export let data: PageData;

  // Local
  const post = data.posts[0];
  let elemPage: HTMLElement | null;

  onMount(() => {
    // Element Page
    elemPage = document.querySelector('#page');
    // CodeBlock Highlight
    document.querySelectorAll('pre code').forEach((elem) => {
      if (!(elem instanceof HTMLElement)) return;
      hljs.highlightElement(elem);
    });
    // Table
    document.querySelectorAll('table').forEach((elem) => {
      elem.classList.add('table');
    });
  });   

  function scrollToTop(): void {
    if (elemPage) elemPage.scrollTop = 0;
  }
</script>

<div class="max-w-5xl mx-auto p-4 md:p-12 space-y-8">
  <!-- Breadcrumbs -->
  <ol class="breadcrumb">
    <li class="crumb"><a class="anchor" href="/blog">Blog</a></li>
    <li class="crumb-separator" aria-hidden>&rsaquo;</li>
    <li>Article</li>
  </ol>
  <!-- Header -->
  <header class="space-y-4">
    <h1 class="h1">{post.title}</h1>
    <!-- Featured Image -->
    {#if post.feature_image}<img
        src={post.feature_image}
        alt={post.title}
        class="w-full aspect-video rounded-container-token shadow-xl"
      />{/if}
  </header>
  <!-- Article -->
  <article class="prose lg:prose-xl max-w-full space-y-8 md:space-y-12">
    {@html post.html}
  </article>
</div>  

HTMLElement 의 classList

{el}.className 또는 {el}.classList 로 특정 요소의 클래스를 조작할 수 있다.

css 살펴보기

  • max-w-full : 너비 100% (최대너비 제한에 사용)
  • prose-xl : typography 에서 폰트 사이즈 1.25rem 지정

3. Skeleton - Utilities

Code Blocks

highlight.js 를 이용해 코드 블럭을 출력한다. (지원하는 언어 리스트)

설치

  1. highlight.js 설치
  2. +layout.svelte 에서 필요한 lang 들만 선별하여 hljs 설정
  3. +layout.svelte 에서 hljs 의 style css 설정
  4. +layout.svelte 에서 storeHighlightJs 에 설정을 저장
  5. vite.config.ts 에서 purgeCss 에 hljs 를 제외하도록 설정
1
bun add -d highlight.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- +layout.svelte -->
<script>
  // Dependency: Highlight JS
  import hljs from 'highlight.js/lib/core';
  import xml from 'highlight.js/lib/languages/xml';
  import css from 'highlight.js/lib/languages/css';
  import json from 'highlight.js/lib/languages/json';
  import javascript from 'highlight.js/lib/languages/javascript';
  import typescript from 'highlight.js/lib/languages/typescript';
  import shell from 'highlight.js/lib/languages/shell';
  hljs.registerLanguage('xml', xml);
  hljs.registerLanguage('css', css);
  hljs.registerLanguage('json', json);
  hljs.registerLanguage('javascript', javascript);
  hljs.registerLanguage('typescript', typescript);
  hljs.registerLanguage('shell', shell);
  // hljs style css
  import 'highlight.js/styles/github-dark.css';
  // save hljs settings to store
  import { storeHighlightJs } from '@skeletonlabs/skeleton';
  storeHighlightJs.set(hljs);
</script>  
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// vite.config.ts
import { purgeCss } from 'vite-plugin-tailwind-purgecss';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    sveltekit(),
    purgeCss({
      safelist: {
        // any selectors that begin with "hljs-" will not be purged
        greedy: [/^hljs-/],
      },
    }),
  ],
  ssr: {
    noExternal: ['svelte-hero-icons'],
  },
});

사용법

1
2
3
4
5
6
7
8
9
10
11
12
<script>
  import { CodeBlock } from '@skeletonlabs/skeleton';
</script>

<CodeBlock
  language="html"
  code={`
<p>
  The quick brown fox jumped over the lazy dog.
</p>
`}
/>

Popups

@floating-ui/dom 를 사용해 팝업 기능을 제공한다.

설치

  1. @floating-ui/dom 설치
  2. +layout.svelte 에서 floating-ui 설정을 storePopup 에 저장 (선택사항)
1
bun add -d @floating-ui/dom

사용법

  • use:popup : popup 기능과 setttings 를 사용
  • data-popup : 컴파일 할 때 변환
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script>
  import { popup, type PopupSettings } from '@skeletonlabs/skeleton';
  const popupClick: PopupSettings = {
    event: 'click',
    target: 'popupClick',
    placement: 'top'
  };  
</script>

<button class="btn variant-filled" use:popup={popupClick}>Click</button>

<div class="card p-4 variant-filled-primary" data-popup="popupClick">
  <p>Click Content</p>
  <div class="arrow variant-filled-primary" />
</div>          

Toasts

하단에 잠시 노출되는 알림창이다. ex) url 클립보드 복사 버튼 클릭시 알림

설치

  1. +layout.svelte 에서 initializeStores 실행
  2. +layout.svelte 에서 최상위(AppShell 바깥쪽)에 Toast 요소 작성

사용법

1
2
3
4
5
6
7
8
9
10
11
<script>
  import { getToastStore } from '@skeletonlabs/skeleton';
  const toastStore = getToastStore();  

  const t: ToastSettings = {
    message: 'Lorem ipsum dolor sit amet consectetur adipisicing elit...',
  };
  toastStore.trigger(t);
  // toastStore.close();  // 메시지 즉시 닫기
  // toastStore.clear();  // 메시지 큐를 비움
</script>

9. Review

  • Skeleton 은 AppShell, AppBar 등의 레이아웃 컴포넌트가 유용하다.
  • ghost CMS 에는 comments 기능도 있다. 살펴봐야겠다.
  • Tailwind CSS 튜토리얼을 마치고 돌아와야겠다.
    • 템플릿 구조는 알겠지만, 복붙할 생각 아니면 스타일을 수정할 수 있어야한다.

 
 

끝!   읽어주셔서 감사합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.