Go민보다 Go

프론트엔드 개발자

Create

[vue] 전체 선택이 있는 체크박스 그룹 만들기

SleepingOff 2024. 4. 15. 22:51

인턴에서 vue를 배웠습니다. 인턴은 끝났지만 vue를 배운 걸 유지하고 싶어서 스터디에 참여했습니다. 스터디 과제에서 커밋한 내용을 토대로 블로그 글을 작성하였습니다.

6. Computed Properties/book/src/components/Filter.vue

 

구현

전체 선택이 있는 체크박스 그룹의 UI는 다음과 같습니다. 버튼처럼 보이도록 스타일을 조정했고, 기본적으로 전체 선택으로 되어있습니다.

체크박스와 라디오를 이용해서 전체 선택이 있는 체크박스들을 만들고 있습니다. 전체 선택과 그 외의 선택 사항들을 두 그룹으로 보았을 때 라디오 처럼 동작하기 때문에, 라디오 input type을 사용하는 것이 좋다고 생각했습니다. 또한, 그 외의 사항들 그룹은 여러 개를 중복 선택할 수 있으므로 체크박스 input type을 사용했습니다. 그렇게 해서 나온 template는 다음과 같습니다.

<template>
    <label @click="onClickRadio"><input type="radio" name="all" v-model="modelValue" :checked="modelValue" />
            <span>전체</span>
    </label>
    <label ref="radioLabel">
        <input type="radio" name="all" v-model="inversedModelValue" :checked="inversedModelValue" />
        <label v-for="(item) in filterList" :key="item.id" @click="onClickCheckBox">
            <input type="checkbox" name="" v-model="item.checked" :checked="item.checked" />
            <span>{{ item.name }}</span>
        </label>
    </label>
</template>

radio로 사용한 input에 name 속성을 동일하게 주면 해당 input(radio) 끼리 연결되어 처리됩니다. 즉, 한 쪽이 선택되어 있으면 다른 쪽은 선택이 해제됩니다. 임의로 만들지 않고, 기본에 충실하여 제공된 기능을 사용하려고 했습니다.

처음에 작성한 script는 다음과 같습니다. 결과적으로 잘 작동하지 않았습니다.

<script setup lang='ts'>
import { computed, ref, watch } from 'vue';

type Filters = FilterType<string>;

type Proptype = {
    filterList: Array<Filters>
}

const prop = defineProps<Proptype>();
const filterList = ref(prop.filterList);

const radioLabel = ref<HTMLLabelElement | undefined>();
const modelValue = ref<boolean>(true);
const inversedModelValue = computed({
    get: () => !modelValue.value,
    set: (value: boolean) => {
        const allChecked = filterList.value.every(item => item.checked);
        console.log(allChecked);
        if (allChecked) {
            unCheckAll();
            return false;
        }
        return value;
    }
})

const onClickRadio = () => {
    inversedModelValue.value = false;
}

const onClickCheckBox = () => {
    if (!radioLabel.value) return;
    modelValue.value = false;
    inversedModelValue.value = !modelValue.value;
}

const unCheckAll = () => {
    filterList.value = filterList.value.map((item: FilterType<string>) => ({ ...item, checked: false }))
}
</script>

이슈

체크박스에서 모두 체크했을 때, 마지막 체크박스만 ui 상태가 바뀌지 않고 있습니다. 문제는 ui 상태만 바뀌지 않는다는 것입니다. 값은 분명 제대로 바뀌었는데, 뷰에서 인식을 잘 못하는 것입니다.

 상황을 다시 설명하자면, ref, reactive 등으로 데이터 바인딩을 위해 선언한 변수의 값, 즉 반응성으로 관리되는 값들을 한 번의 렌더링에서 연속적으로 바꾸면 제대로 적용이 되지 않습니다.

강제로 렌더링을 한 번 더 하거나 onMounted를 이용해서 값이 바뀌는 것에 타이밍 차이를 두면 될테지만, 강제로 생명주기에 끼어드는 것이 되므로 지양하는 게 좋다고 생각합니다.

그러니 다른 방법을 알아봐야 합니다. 먼저, 데이터가 바뀌어야 되는 순서를 그려보았습니다. 현재 로직에서 데이터가 어떻게 변하는지 써보았습니다.

결과적으로 생각하고 고치니 잘 됩니다. 이미지의 빨간색으로 쓴 글입니다.

데이터 바인딩을 하나씩 제어하기 보단 서로의 관계를 파악해보자.

filterList -> inversedModelValue -> modelValue

1. inversedModelValue가 변하면 modelValue의 값이 같이 변해야 한다.
2. inversedModelValue의 값은 filterList의 allChecked 검사 후 변해야 한다.

따라서, filterList의 값 변경을 watch로 관찰하여 inversedModelValue의 값을 변경하고,
modelValues는 computed를 이용하여 뷰가 계산하도록 한다.

+
filterList 자체는 deep 하기 때문에 watch가 관찰할 때 비용이 많이 소모한다.
computed로 checkedList를 만들어 checked된 상태만 가져오는 배열을 관찰한다.

적용 후 코드

스타일과 로컬에 필터를 저장하는 것까지 추가해서 작성하였습니다. 위의 script와 달라진 점은 modelValue와 inversedModelValue가 서로 바뀌었다는 것입니다.

<template>
    <label @click="onClickRadio"><input type="radio" name="all" v-model="inversedModelValue"
            :checked="inversedModelValue" />
        <span>전체</span>
    </label>
    <label>
        <input type="radio" name="all" v-model="modelValue" :checked="modelValue" />
        <label v-for="(item) in filterList" :key="item.id" @click="onClickCheckBox">
            <input type="checkbox" name="" v-model="item.checked" :checked="item.checked" />
            <span>{{ item.name }}</span>
        </label>
    </label>
</template>

<script setup lang='ts'>
import { computed, ref, watch } from 'vue';
type Filters = FilterType<string>;
type Proptype = {
    filterList: Array<Filters>
}
const prop = defineProps<Proptype>();
const filterList = ref(prop.filterList);
const checkedList = computed(() => filterList.value.map(item => item.checked))
const modelValue = ref<boolean>(false);
const inversedModelValue = computed(() => !modelValue.value)

const onClickRadio = () => {
    modelValue.value = false;
    unCheckAll();
}

const onClickCheckBox = () => {
    modelValue.value = true;
}

const unCheckAll = () => {
    filterList.value = filterList.value.map((item: FilterType<string>) => ({ ...item, checked: false }));
}

watch(checkedList, () => {
    const allChecked = checkedList.value.every(item => item);
    const allUnChecked = checkedList.value.every(item => !item);
    if (allChecked || allUnChecked) {
        modelValue.value = false;
    }
})

</script>

<style scoped>
label {
    margin: 8px;
}

span {
    border: 1px solid black;
    border-radius: 4px;
    background-color: white;
}

input {
    clip: rect(1px, 1px, 1px, 1px);
    clip-path: inset(50%);
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    padding: 0;
    position: absolute;
}

input[type="checkbox"]:checked~span {
    background-color: grey;
    color: white;
}

input[type="radio"]:checked~span {
    background-color: blue;
    color: white;
}

/* name속성이 작동하지 않음 */
input[type="radio"]:not(:checked)~span,
input[type="radio"]:not(:checked)~label>span {
    background-color: rgb(224, 224, 224);
}

</style>

input의 name 속성을 준 radio 끼리는 서로 비활성화된 상태가 잘 보일 거라 생각했는데, input을 숨기고 span으로 대체하니 그 부분이 보이지 않았습니다. 그래서 별도의 스타일을 추가해서 사용했습니다.

728x90