SvelteKit Tailwind 튜토리얼 - 6일차
포스트
취소

SvelteKit Tailwind 튜토리얼 - 6일차

TailGrids 의 샘플 Templates 들을 Svelte5 로 변환하며 runes 와 Tailwind CSS 사용법을 공부합니다. 외워질 때까지 여러번 반복하여 숙달합니다.

0. 개요

1. 프로젝트 생성

참고

TailGrids - Dashboard and Admin Template

top 화면

TailGrids - Dashboard and Admin Template

전체 화면

TailGrids - Dashboard and Admin Template

Template Section and Pages

  • Home
  • NavMenu
  • Dashboard
  • Orders
  • Messages
  • Notifications
  • Sales
  • Events
  • Charts
  • Docs
  • Settings
  • Account Settings
  • Log In and Log Out
  • Sales Analytics - Chart and Graph
  • Revenue and Sales Section
  • Product Table
  • 404 Page

2. +page.svelte

작업 절차

  1. 상단의 메뉴바 작성 (절대위치)
  2. 상단 메뉴바를 감싸는 전체 container 작성
  3. 상단 메뉴바 보다 우선순위를 갖는 사이드 메뉴바를 작성하여 끼워넣기
  4. 사이드바 토글 버튼을 만들고 open 상태 변수를 작성하여 translate 클래스와 연결
  5. 사이드바 open 때에 전체 화면을 반투명하게 가리는 outside 영역을 작성하고 닫기 연결
  6. Body 부분 (HeaderSection 과 ContentSection) 작성

Admin 레이아웃

  • 사이드바와 사이드바 핸들러(열고 닫기)를 먼저 작성하고
    • 사이드바는 초기에 회면 왼쪽(마이너스 x 위치)에 숨겨진 상태로 존재하다 나타난다.
  • Body 영역에서 Header 와 Content 섹션을 작성한다.
    • HeaderSection 은 상단에 고정이지만,
    • ContentSection 은 메뉴 선택에 따라 내용이 바뀐다.

전체 코드

  • 생략가능(optional) 함수 파라미터의 JSDoc 표현식 : @param {boolean=} value
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
<script>
  // 사이드 메뉴
  const sidebarController = (() => {
    /** @type {boolean} */
    let isOpen = $state(false);
    return {
      /** @param {boolean=} value */
      toggle: (value = undefined) => {
        if (value === undefined) {
          isOpen = !isOpen;
        } else {
          isOpen = value;
        }
      },
      get open() {
        return isOpen;
      },
    };
  })();
</script>

<section class="relative flex min-h-screen w-full items-start bg-base-100">
  <!-- 사이드바 위치 고정 -->
  <div
    class="{sidebarController.open
      ? 'translate-x-0'
      : '-translate-x-full'} absolute left-0 top-0 z-40 flex min-h-screen w-full max-w-[90px] flex-col justify-between bg-base-200 shadow-md duration-200 xl:translate-x-0"
  >
    <!-- || Sidebar menu Start -->
    <SidebarSection />
    <!-- || Sidebar menu End -->
  </div>

  <!-- Sidebar outside click -->
  <div
    onclick={() => {
      sidebarController.toggle();
    }}
    class="{sidebarController.open
      ? 'translate-x-0'
      : '-translate-x-full'} fixed left-0 top-0 z-30 h-screen w-full bg-neutral bg-opacity-80 xl:hidden"
  ></div>

  <!-- Body Area -->
  <div class="w-full xl:pl-[90px]">
    <!-- || Header Menu Section Start -->
    <HeaderSection {sidebarController} />
    <!-- || Header Menu Section End -->

    <!-- Content Area -->
    <div class="p-[30px]">
      <SalesSection />
    </div>    
  </div>  
</section>

3. 레이아웃

사이드바

  • 로고 이미지 (링크)
  • 툴바 : 홈, 대시보드, 오더, 메시지, 판매, 이벤트, 차트, 문서
  • 툴바 분리선
  • 툴바 : 메시지, 설정, 로그아웃
  • 프로파일 아바타

사이드바 close 상태 (light 테마)

Admin Template - Sidebar Layout - Close

사이드바 open 상태 (dark 테마)

Admin Template - Sidebar Layout - Open

sidebar-section.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
<script>
  import LogoSvg from '$lib/assets/images/logo/logo.svg';
  import Avatar5Img from '$lib/assets/images/avatar/image-05.jpg';
</script>

<aside>
  <!-- 로고(Home) -->
  <div class="px-7 pb-7 pt-9">
    <a href={undefined}>
      <img src={LogoSvg} alt="logo" class="h-8 w-8" />
    </a>
  </div>

  <!-- 툴바 -->
  <nav>
    <ul>
      <!-- Home 버튼 -->
      <li class="group relative">
        <!-- 아이콘 링크 -->
        <a
          href={undefined}
          class="relative flex items-center justify-center border-r-4 border-transparent px-9 py-3 text-base font-medium text-base-content duration-200 hover:border-primary hover:bg-primary/5 hover:text-primary"
        >...</a>
        <!-- 라벨 (hover 할 때 translate 로 출력) -->
        <span
          class="invisible absolute left-[115%] top-1/2 -translate-y-1/2 whitespace-nowrap rounded-[5px] bg-base-100 px-[14px] py-[6px] text-sm text-base-content shadow-md group-hover:visible"
        >...</span>
      </li>
      <!-- 나머지 툴바 버튼들... -->
      <li class="group relative">...</li>
    </ul>
  </nav>

  <!-- Profile : Avatar(image) -->
  <div class="px-6 py-10">
    <div class="flex items-center">...</div>
  </div>
</aside>

상단 메뉴바

  • 사이드바 open 버튼
  • 검색창
  • 툴바 : light/dark 테마, 칼렌더, 알림, 메시지, 프로파일

header-section.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
<header class="w-full bg-base-100">
  <div
    class="relative flex items-center justify-end bg-base-200 py-3 pl-[70px] pr-3 sm:justify-between md:pl-20 md:pr-8 xl:pl-8"
  >
    <!-- Hamburger Menu Button -->
    <button
      onclick={() => {
        sidebarController.toggle(true);
      }}
    >...</button>

    <!-- Search Input Box -->
    <div class="hidden sm:block">
      <div class="flex items-center">
        <span>...</span>
        <input type="text" ... />
      </div>
    </div>

    <!-- Toolbar -->
    <div>
      <div class="flex items-center">
        <!-- Theme Changer : light/dark -->
        <div class="mr-5 mt-2 hidden md:block">
          <!-- daisyui theme-controller ... -->
        </div>
        <!-- Calendar -->
        <div class="mr-5 hidden md:block">...</div>
        <!-- Alert -->
        <div class="relative mr-5 hidden md:block">...</div>
        <!-- Email -->
        <div class="relative mr-5 hidden md:block">...</div>
        <!-- Profile -->
        <div class="group relative">...</div>       
      </div>
    </div>

  </div>
</header>  

4. 컴포넌트

스코어 카드

component-score-card.png

숫자값 등을 출력하는 대시보드용 스코어 카드 컴포넌트에 대한 코드이다. 동일한 형태를 갖지만 내용물만 다른 경우 프로퍼티를 전달하여 재사용하도록 작성할 수 있다.

  • svgIcon : 이전에 slot 으로 작성되던 위치에 snippet 으로 대치하여 작성
    • 컴포넌트 내부에 정의될 경우 attribute 로 지정 안해도 $props 로 받게 된다.
  • 고정 개수이면 each loop 로 생성하는 것보다 물리적으로 반복하는 것이 보기 편하다.
    • 동적 개수이면 loop 로 처리하지만, 고정 형태라면 중복 작성이 옳다.
  • 각 카드의 DropDown 메뉴 핸들러는 createDropdownHandle 로 생성시마다 호출
    • 상태 변수 open 과 toggle 함수를 각각 별개의 객체로 전달

Parent Component

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
<script>
  // create handler of dropdown menu
  const createDropdownHandle = () => {
    /** @type {boolean} */
    let isOpen = $state(false);
    return {
      /** @param {boolean=} value */
      toggle: (value = undefined) => {
        if (value === undefined) {
          isOpen = !isOpen;
        } else {
          isOpen = value;
        }
      },
      get open() {
        return isOpen;
      },
    };
  };

  /** @type { {
   *    value: string,
   *    description: string,
   *  }[] }
   */
  let scoreItems = [
    { value: '$4,350', description: 'Earned this month' },
    { value: '583', description: 'New Clients' },
    { value: '1289', description: 'New Sales' },
  ];

  import ChartCard from './chart-card.svelte';
  import ScoreCard from './score-card.svelte';
</script>

<ScoreCard
  value={scoreItems[0].value}
  description={scoreItems[0].description}
  handler={createDropdownHandle()}
>
  {#snippet svgIcon()}
    <svg
      width="30"
      height="31"
      viewBox="0 0 30 31"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
      class="fill-current"
    >
      <path d="M18.5156 ... 14.4688Z" />
    </svg>
  {/snippet}
</ScoreCard>

<ScoreCard ... />
<ScoreCard ... />

ScoreCard (Child)

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
<script>
  /** @type { {
   *    value: string,
   *    description: string,
   *    handler: {
   *      open: boolean,
   *      toggle: (val?: boolean) => void,
   *    },
   *    svgIcon: import('svelte').Snippet,
   *  } }
   */
  let { value, description, handler, svgIcon } = $props();
</script>

<div
  class="relative mb-8 flex items-center rounded-[10px] px-6 py-10 shadow-sm sm:px-10 md:px-6 xl:px-10"
>
  <div
    class="mr-4 flex h-[50px] w-full max-w-[50px] items-center justify-center rounded-full bg-primary text-primary-content sm:mr-6 sm:h-[60px] sm:max-w-[60px] md:mr-4 md:h-[50px] md:max-w-[50px] xl:mr-6 xl:h-[60px] xl:max-w-[60px]"
  >
    {@render svgIcon()}
  </div>
  <div>
    <p class="text-2xl font-bold text-primary-content xl:text-[28px] xl:leading-[35px]">
      {value}
    </p>
    <p class="mt-1 text-base text-base-content">{description}</p>
  </div>
</div>

chart.js 차트

chartjs-barchart-demo

+page.svelte

1
2
3
4
5
6
7
8
9
10
<script>
  import { bindBarChart } from './chartjs-bar.svelte';
</script>

<section class="container mx-auto">
  <h1 class="pb-4 text-xl">Bar Chart</h1>
  <div class="w-[800px] border">
    <canvas use:bindBarChart></canvas>
  </div>
</section>

chartjs-bar.svelte.js

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
import { Chart as ChartJS } from 'chart.js/auto';

/** @param { HTMLCanvasElement } node */
export function bindBarChart(node) {

  const data = {
    labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'],
    datasets: [
      {
        label: '# of Votes',
        data: [12, 19, 3, 5, 2, 3],
        borderWidth: 1,
      },
    ],
  };

  const config = {
    type: 'bar',
    data: data,
    options: {
      scales: {
        y: {
          beginAtZero: true,
        },
      },
    },
  };

  /** @type { import('chart.js').Chart | undefined } */
  let barChart = new ChartJS(node, config);

  return {
    destroy() {
      if (barChart) barChart.destroy();
      barChart = undefined;
    },
  };
}

Canvas 바탕색상 변경

비슷한 방법으로 dark mode 에서 chart 의 색상, 스타일 등을 변경하는데 사용할 수 있다.

참고

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const canvasBgColorConfig = {
  id: 'customCanvasBackgroundColor',
  beforeDraw: (chart, args, options) => {
    const { ctx } = chart;
    ctx.save();
    ctx.globalCompositeOperation = 'destination-over';
    ctx.fillStyle = options.color || '#99ffff';
    ctx.fillRect(0, 0, chart.width, chart.height);
    ctx.restore();
  },
};

let barChart = new ChartJS(ctx, {
  type: 'bar',
  data: { /* ... */ },
  options: {
    plugins: {
      customCanvasBackgroundColor: {
        color: 'lightGreen',
      },
    },
  },
  plugins: [canvasBgColorConfig]
});

Chart.js Actions 추가

차트 canvas 아래에 여러 action 버튼들을 추가해 동작하도록 작성해보았다.

  • chartjs 생성 함수에서 차트 생성 후 emit 이벤트를 발신
  • 차트 컴포넌트에서 chart 객체를 event.detail 로 받아 $state 변수에 저장
  • 차트 컴포넌트에서 chart 객체를 파라미터로 넣어 다양한 action 함수들을 연결

참고 : 공식문서 - Line Chart with Actions

화면캡쳐

chartjs-line-with-actions

sales-section.svelte (Parent 컴포넌트)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
  import { bindLineChart, lineChartActions } from './chartjs-line.svelte';
</script>

<!-- Chart #3 (with Actions) -->
<ChartCard
  title="$35,8K"
  description="Overall Revenue"
  options={[
    { value: 'monthly', name: 'Monthly' },
    { value: 'yearly', name: 'Yearly' },
  ]}
  chartfn={bindLineChart}
  class="2xl:w-5/12"
  actions={lineChartActions}
></ChartCard>

chartjs-line.svelte.js (js 모듈)

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
import { Chart as ChartJS } from 'chart.js/auto';
import * as Utils from '$lib/utils/chartjs-utils';

export const lineChartActions = [
  {
    name: 'Randomize',
    /** @param { import('chart.js').Chart } chart */
    handler(chart) {
      chart.data.datasets.forEach((dataset) => {
        if (chart.data.labels)
          dataset.data = Utils.numbers({ count: chart.data.labels.length, min: -100, max: 100 });
      });
      chart.update();
    },
  },
  { name: 'Add Dataset', handler(chart) {...} },
  { name: 'Add Data', handler(chart) {...} },
  { name: 'Remove Dataset', handler(chart) {...} },
  { name: 'Remove Data', handler(chart) {...} },
];

////////////////////////////////////////////////

/** @param { HTMLCanvasElement } node */
export function bindLineChart(node) {
  // create chartjs
  // ...

  // 차트가 생성되면 emit 이벤트를 발신(dispatch)
  node.dispatchEvent(new CustomEvent('emit', { detail: lineChart }));

  return {...};
}  

chart-card.svelte (Child 컴포넌트)

  • 상위 컴포넌트에서 actions 를 전달받고 action 버튼 생성
  • chart 생성 함수에서 전달한 chart 객체를 저장하고 action handler 함수에 전달
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
<script>
  /** @type { {
   *    title: string,
   *    description: string,
   *    options: any[],
   *     chartfn: (node: HTMLCanvasElement) => {destroy(): void;} | undefined,
   *     actions?: {name:string, handler(chart:import('chart.js').Chart):void}[],
   *     class?: string,
   *  } }
   * */
  let { title, description, options, chartfn, actions, ...restProps } = $props();
  let customClass = restProps.class ?? 'lg:w-1/2 xl:w-7/12 2xl:w-5/12';
  let chart = $state(undefined);

  /**
   * chart 하위 컴포넌트에서 emit 이벤트 발생시 실행되는 함수
   * @param {CustomEvent} event
   */
  function handleEmit(event) {
    /** @type { import('chart.js').Chart | undefined } */
    chart = event.detail ?? undefined;
  }
</script>

<!-- Line 차트 -->
<div class="flex h-[380px] items-center justify-center">
  {#if chartfn !== undefined}
    <canvas class="my-4" on:emit={handleEmit} use:chartfn></canvas>
  {/if}
</div>

<!-- Actions 버튼 그룹 -->
{#if actions}
  <div class="join mt-6 flex place-content-center gap-2">
    {#each actions as action}
      <button
        class="btn join-item bg-primary-content {!chart && 'btn-disabled'}"
        onclick={() => {
          console.log('action clicked:', action.name, !!chart);
          if (chart) action.handler(chart);
        }}>{action.name}</button
      >
    {/each}
  </div>
{/if}

9. Review

  • 테이블, 다이얼로그 입력박스 등 나머지는 다음에..
    • 테이블 코드는 daisyui 샘플 코드를 붙여 넣었다.
    • 테이블에 thead 와 tbody 만 있는줄 알았는데, tfoot 도 있었다.
  • 차트 컴포넌트 구현시 svelte-chartjs 를 써도 되지만, svelte5 연습을 위해 사용하지 않았다.
    • 본래 Svelte 의 장점은 js 라이브러리들을 자유롭게 사용할 수 있다는 점이었다.
  • 화면을 동영상 캡쳐해서 animated GIF 파일로 변환하는게 번거롭다. (쉬운 방법 없나?)

 
 

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

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