인턴에서 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으로 대체하니 그 부분이 보이지 않았습니다. 그래서 별도의 스타일을 추가해서 사용했습니다.
'Create' 카테고리의 다른 글
[React] 컴포넌트는 한 번에 하나의 책임만 진다. (0) | 2024.05.05 |
---|---|
[package] 나 뭘 알고 있을까? (0) | 2024.04.28 |
[front] 프로젝트 초기 세팅: linter, formatter (1) | 2024.04.19 |
[figma] github과 연결하고 토큰 가공하기 (0) | 2024.04.17 |
Storybook을 도입하자 컴포넌트가 정리됐다 (0) | 2023.12.02 |