본문 바로가기
SPA

Svelte Material Todos (2)

by NOMADFISH 2022. 8. 31.

시작

svelte type script example. Contribute to MadfishDT/svelte-basic development by creating an account on GitHub.

github.com](https://github.com/MadfishDT/svelte-basic/tree/master/todo)

구성

  • 가장 대중적인 UI Framwork인 Material UI를 기반으로 전체적인 UI를 구성한다.
  • 기본 화면의 구성은 Todo 전체 리스트, Todo 생성 화면으로 간단하게 구성하여, Todo 생성 화면에서 생성한 Todo가 Todo 전체 리스트에 보이는 방식이다.
  • 이를 위해서 TodoList, NewDialog라는 2개의 Component를 만들었다.
  • Todo를 표현하기 위한 Interface를 정의 하였다.
    • 보통 Todo는 제목, 설명, 생성일일자 정도로 구성되어 있다, 추가로 favorite(먼저볼거), selected(현재 선택된 아이템), done(완료 표기), end(완료일자) 정보를 추가 하였다.
    • Material Card UI를 사용하여, Card를 여러개를 한화면에 세로로 뿌리는 형태를 사용한다.
    • 단순이 카드만 뿌리기 때문에 svelte each 문을 사용하여, todo item들을 TodoCard component를 이용하여 뿌리는 작업만 구현된다.
export interface iTodo {
    id?: number;
    desc?: string;
    name: string;
    date: Date;
    end?: Date;
    favorite?: boolean;
    selected: boolean;
    done?: boolean;
}
  • Store 구성
    • Todo 정보 리스트
      • Todo 추가 함수
      • Favorite Todo지정 함수
      • Checkd Todo(완료한 Todo) 지정 함수
    • Todo 신규 작성 Dialog Open/Close 상태 값
import { writable, get } from "svelte/store";
import type { Todos, iTodo } from "./interface";

export const datas = writable<Todos>([]);
export const selected_items = writable<Todos>([]);
export const openNewTodoDialog = writable<boolean>(false);
let todos: Todos = [];

datas.subscribe((value) => {
    todos = value;
});

export const addTodo = (item: iTodo) => {
    const newDatas = [...get(datas)];
    item.id = newDatas.length;
    newDatas.push(item);
    datas.set(newDatas);
};

export const favorite = (item: iTodo) => {
    const current = get(datas);
    const index = current.findIndex((i) => i.id == item.id);
    if (index >= 0) current[index].favorite = true;
    datas.set([...current]);
};

export const checked = (item: iTodo) => {
    const current = get(datas);
    const index = current.findIndex((i) => i.id == item.id);
    if (index >= 0) current[index].selected = true;
    datas.set([...current]);
};

export const openDialog = () => {
    const current = get(openNewTodoDialog);
    if (!current) openNewTodoDialog.set(true);
};

export const closeDialog = () => {
    openNewTodoDialog.set(false);
};
  • APP Layout 구성
    • 보토의 mobile app과 비슷한 구성으로 상단 헤더에 버튼과 제목 
    • 메인 화면에는 카드 형식의 리스트
    • 팝업 형식으로 구성
    • svelte:head의 경우 smui, front, icon등을 상용하기 위한 css 를 code실행전 포함 시키기 위해서 사용하였습니다.
<script lang="ts">
    import { onMount } from "svelte";
    import Router from "svelte-spa-router";
    import routes from "./router";
    import { SmapleData } from "./interface";

    import TopAppBar, { Row, Section, Title } from "@smui/top-app-bar";
    import IconButton from "@smui/icon-button";
    import NewTodoDialog from "./NewDialog.svelte"
  
    
    import { datas, openDialog } from "./store";

    export let prominent = false;
    export let dense = false;
    export let secondaryColor = false;

    const loadData = () => {
        datas.set(SmapleData);
    };
    onMount(() => {
        loadData();
    });
</script>

<svelte:head>
    <!-- Fonts -->
    <link
        rel="stylesheet"
        href="https://fonts.googleapis.com/icon?family=Material+Icons"
    />
    <link
        rel="stylesheet"
        href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,600,700"
    />

    <!-- Material Typography -->
    <link
        rel="stylesheet"
        href="https://unpkg.com/@material/typography@13.0.0/dist/mdc.typography.css"
    />

    <!-- SMUI -->
    <link
        rel="stylesheet"
        href="https://unpkg.com/svelte-material-ui/bare.css"
    />
</svelte:head>

<main>
    <div class="top-app-bar-container flexor">
        <NewTodoDialog />

        <TopAppBar
            variant="static"
            {prominent}
            {dense}
            color={secondaryColor ? "secondary" : "primary"}
        >   
            <Row>
                <Section>
                    <Title>Mad Todos</Title>
                </Section>
                <Section align="end" toolbar>
                    <IconButton class="material-icons" aria-label="add_todo" on:click={() => openDialog()}
                        >add</IconButton
                    >
                    <IconButton
                        class="material-icons"
                        aria-label="Bookmark this page">delete</IconButton
                    >
                </Section>
            </Row>
        </TopAppBar>
        <div class="content-container">
            <Router {routes} />
        </div>
    </div>
</main>

<style>
    main {
        text-align: center;
        padding: 1em;
        max-width: 480px;
        height: 100%;
        margin: 0 auto;
    }
    .top-app-bar-container {
        max-width: 480px;
        width: 100%;
        height: 80%;
        border: 1px solid
            var(--mdc-theme-text-hint-on-background, rgba(0, 0, 0, 0.1));
        background-color: var(--mdc-theme-background, #fff);
        overflow-y: hidden;
        overflow-x: hidden;
        display: inline-block;
    }
    .content-container {
        max-width: 480px;
        width: 100%;
        height:100%;
        border: 1px solid
            var(--mdc-theme-text-hint-on-background, rgba(0, 0, 0, 0.1));
        background-color: var(--mdc-theme-background, #fff);
        overflow-y: auto;
        overflow-x: hidden;
        display: inline-block;
    }
    .top-app-buttons {
        margin-bottom: 0px;
    }
    .flexy {
        display: flex;
        flex-wrap: wrap;
    }
    .flexor {
        display: inline-flex;
        flex-direction: column;
    }
    .flexor-content {
        flex-basis: 0;
        height: 0;
        flex-grow: 1;
    }
    h1 {
        color: #ff3e00;
        text-transform: uppercase;
        font-size: 4em;
        font-weight: 100;
    }

    @media (min-width: 480px) {
        main {
            max-width: none;
        }
    }
</style>
  • TodoList Component 구성
    • TodoList는 TodoCard 여러개로 구성 됩니다. 아래 코드에서 볼 수 있는 것과 같이 단순히 카드 리스트를 For문으로 뿌리고 있습니다.
    • 단 이때 Store에 저장된 datas -> todo정보들을 info라는 곳에 bind해서 넣어주고 있습니다.
    • 단순히 readonly라서 bind는 반드시 해줄 필요가 없습니다.
<script lang="ts">
    import TodoCard from "./TodoCard.svelte";
    import { datas } from "./store";
    import type { Todos } from "./interface";

    let todos: Todos = [];

    datas.subscribe((value) => {
        todos = [...value].reverse();
    });
</script>

<main>
    {#each todos as item}
        <TodoCard bind:info={item} />
    {/each}
</main>

<style>
    main {
        text-align: center;
        padding: 1em;
        max-width: 100%;
    }
</style>
  • TodoCard 
    • Todo Card는 아래와 같습니다. SMUI를 이용하여 기본 카드에 버튼과 Text로 이루어져 있습니다.
    • 카드에 뿌릴 정보는 위에 정의된 iTodo라는 Interface 타입의 객체인 info 속성에서 가져와 뿌리고 있습니다.
    • info는 TodoList에서 TodoCard에 할당하는 속성입니다.
<script lang="ts">
    import Card, {
        PrimaryAction,
        Content,
        Actions,
        ActionIcons,
    } from "@smui/card";
    import IconButton from "@smui/icon-button";
    import { Icon } from "@smui/common";
    import type { iTodo } from "./interface";
    import { favorite } from "./store";
    import moment from "moment";
    export let info: iTodo | undefined = undefined;

    export let favoriteToggle = info ? info.favorite : false;
    export let checkedToggle = info ? info.selected : false;
</script>

<main>
    <div class="card-display">
        <div class="card-container">
            <Card>
                <div class="flexor" style="margin-left: 10px; margin-top:5px;">
                    <span style="font-size:10px;">
                        {#if info && info.date}
                            {moment(info.date).format("YYYY-MM-DD HH:mm:ss")}
                        {/if}
                    </span>
                </div>
                <Actions style="padding-top:0px;">
                    <div style="text-align: left;">
                        <h2
                            class="mdc-typography--headline6"
                            style="margin: 0;"
                        >
                            {#if info && info.name}
                                {info.name}
                            {/if}
                        </h2>
                    </div>
                    <ActionIcons>
                        <IconButton
                            aria-label="Add to favorites"
                            title="Add to favorites"
                            on:click={() => {
                                favoriteToggle = !favoriteToggle;
                                if (favoriteToggle) favorite(info);
                            }}
                        >
                            {#if favoriteToggle}
                                <Icon class="material-icons">favorite</Icon>
                            {:else}
                                <Icon class="material-icons"
                                    >favorite_border</Icon
                                >
                            {/if}
                        </IconButton>
                        <IconButton
                            aria-label="Add to favorites"
                            title="Add to favorites"
                            on:click={() => (checkedToggle = !checkedToggle)}
                        >
                            {#if checkedToggle}
                                <Icon class="material-icons">check_box</Icon>
                            {:else}
                                <Icon class="material-icons"
                                    >check_box_outline_blank</Icon
                                >
                            {/if}
                        </IconButton>
                    </ActionIcons>
                </Actions>

                <PrimaryAction>
                    <Content class="mdc-typography--body2">
                        <div class="flexor">
                            {#if info && info.desc}
                                {info.desc}
                            {/if}
                        </div>
                    </Content>
                </PrimaryAction>
            </Card>
        </div>
    </div>
</main>

<style>
    main {
        text-align: center;
        padding: 1em;
        max-width: 100%;
    }
    .flexor {
        text-align: center;
        display: flex;
        flex-wrap: wrap;
    }
</style>
  • 다이얼 로그
    • 제목과 할일 설명을 작성하고 완료 버튼을 누를 수 있는 단순한 다이얼로그
    • 적용이나 Enter Key 입력시, store의 addTodo 함수를 호출하여 신규 Todo를 TodoList에 추가 한다.
<script lang="ts">
    import Textfield from "@smui/textfield";
    import CharacterCounter from "@smui/textfield/character-counter";
    import Icon from "@smui/textfield/icon";
    import Dialog, { Title, Content, Actions } from "@smui/dialog";
    import Button, { Label } from "@smui/button";
    import { openNewTodoDialog, closeDialog, addTodo } from "./store";
    import type { iTodo } from "./interface";
    import { debounce } from "lodash";

    let open = false;
    let name = "";
    let desc = "";

    const onAddTodo = () => {
        if (name && name.length > 0) {
            const todo: iTodo = {
                id: 0,
                desc: desc,
                name: name,
                date: new Date(Date.now()),
                selected: false,
            };
            name = "";
            addTodo(todo);
            return true;
        }
        return false;
    };
    const onEnterEvent = debounce((e: CustomEvent) => {
        const ke = e as unknown as KeyboardEvent;
        if (ke.code == "Enter") {
            if (onAddTodo()) closeDialog();
        }
    });
    const onClose = () => {
        name = "";
        desc = "";

        closeDialog();
    };
    openNewTodoDialog.subscribe((value: boolean) => {
        open = value;
    });
</script>

<Dialog
    bind:open
    aria-labelledby="buttons-title"
    aria-describedby="buttons-content"
    autoStackButtons={false}
    on:SMUIDialog:closed={(e) => ($openNewTodoDialog = false)}
>
    <Title id="buttons-title">새로운 할일</Title>
    <Content id="buttons-content" style={"width:400px; padding: 14px"}>
        <Textfield
            variant="outlined"
            bind:value={name}
            label="할일 추가"
            style={"width:100%"}
            on:keydown={onEnterEvent}
        >
            <Icon class="material-icons" slot="leadingIcon">work</Icon>
        </Textfield>
        <div style={"width:100%;height:200px;margin-top:13px"}>
            <Textfield
                style={"width:100%;height:100%"}
                textarea
                variant="outlined"
                input$maxlength={100}
                bind:value={desc}
                on:keydown={onEnterEvent}
                label="설명"
            >
                <CharacterCounter slot="internalCounter"
                    >0 / 100</CharacterCounter
                >
            </Textfield>
        </div>
    </Content>
    <Actions>
        <div class="flexy">
            <div>
                <Button on:click={onAddTodo}>
                    <Label>적용</Label>
                </Button>
            </div>
            <div>
                <Button on:click={closeDialog}>
                    <Label>닫기</Label>
                </Button>
            </div>
        </div>
    </Actions>
</Dialog>

<style>
    .flexy {
        display: flex;
        flex-wrap: wrap;
    }
</style>

'SPA' 카테고리의 다른 글

Svelte 3 -> 4 migration  (0) 2023.10.01
Svelte Material Todos (1)  (0) 2022.08.23
Svelte Material UI 적용하기  (0) 2022.05.30
Svelte Store  (0) 2021.10.31
Svelte 글자 바꾸기2  (0) 2021.03.24