본문 바로가기

FrontEnd

[Vue3] Vue3로 접근성 고려한 Form(양식) 개발하기

반응형

Vue3로 접근성을 고려한 Form(양식)을 개발하는 방법의 기초를 학습해 봅시다.

Vue3 Logo

전체 소스 코드 보기 

https://github.com/Code-Pop/Vue-3-Forms/tree/l9-end

 

GitHub - Code-Pop/Vue-3-Forms

Contribute to Code-Pop/Vue-3-Forms development by creating an account on GitHub.

github.com

양식(form)의 기초

1. HTML에서 양식의 기본 동작은 브라우저 탐색(navigation) 이벤트를 트리거하여 지정된 URL로 많은 데이터를 보내는 것입니다.

  • Axios와 같은 라이브러리를 사용하지 않으면 HTML 양식 제출시 브라우저가 완전히 새로운 페이지를 로드하게 합니다다
페이지를 지속적으로 전체 새로고침하지 않고 사용자에게 원활한 탐색을 제공하려는 SPA(단일 페이지 애플리케이션) 시대에
이는 좋은 경험이 아닙니다.
2. 모든 양식은 <form> 태그가 필요합니다.
  • 스크린 리더가 <form> 요소 내 콘텐츠를 처리할 때 종종 "양식 모드"로 전환됩니다.
  • 이것은 accesibility 기능 사용하는 사용자에게 양식을 탐색할 때 더 나은 경험을 제공하며 이를 수행하지 않을 이유가 없습니다.
3. 양식 제출을 트리거하는 방법에는 여러 가지가 있습니다.
  • 제출 버튼 클릭 or 탭 + 클릭 or 탭 + Enter
  • 아무 필드에 포커싱 한 후 Enter 키를 누르면, 스크린 리더는 type="submit" 타입이 있는 버튼을 찾습니다.

그렇다면 우리는 양식을 제출하려는 사용자의 시도를 어떻게 올바르게 파악할까요?

The submit event

사용자가 래핑된 양식 태그 내에서 양식을 제출할 때마다 이 양식 태그는 우리가 리스닝 할 수 있는 submit 이벤트를 내보냅니다(emit).
클릭 리스너를 양식의 제출 버튼에 설정하는 것이 일반적이지만 우리는 이를 피해야 합니다.

대신 양식(form)태그의 이벤트를 리스닝 합니다.

[...]
<form @submit.prevent="sendForm">
[...]
 
양식 태그 내에서 버튼의 클릭 이벤트를 수신(및 기본 동작 방지)하면
submit 이벤트를 방출한 뒤, 나중에 해당 양식 내에서 데이터를 보내는 HTML 양식의 기본 동작을 효과적으로 차단합니다.
그러나 모든 양식을 제출하는 방법을 커버하지 않습니다.
 
예를 들어 type="submit" 애트리뷰트가 있는 버튼 클릭 시 양식을 제출하는 것 또한 양식의 바람직한 동작입니다.
하지만 type="submit" 버튼이 양식 내에 하나도 없으면, 브라우저는 양식 내 모든 버튼이 submit을 위한 버튼이라 가정합니다.
반대로 양식에 취소 버튼과 같은 다른 유형의 버튼이 필요한 경우 type="button"을 추가하면
해당 버튼 클릭 시 양식 제출 이벤트가 발생하지 않습니다.
 
 
 @submit 이벤트 리스너에서 이벤트 수정자(modifier) .prevent를 사용하고 있음에 주의하세요
이 수정자는 제출 이벤트에서 preventDefault를 호출하므로
sendForm 메서드는 이벤트 처리가 아닌 양식 제출 논리에만 집중할 수 있습니다.
 
양식 요소에 대한 제출 이벤트에 preventDefault를 설정하면
양식이 자체적으로 데이터를 제출하고 브라우저를 다시 로드하는 기본 동작이 차단됩니다.
우리는 양식이 처리되는 방식을 계속 제어하고 Axios를 사용하여 데이터를 제출하기를 원하므로
기본 동작을 방지하는 것이 필요합니다.
(Axios의 Post를 이용해 양식 제출 - 물론 form은 get 메소드를 활용할 수도 있음- 이 경우 쿼리스트링을 이용)

 

폼 컴포넌트 설계

이벤트의 추상화 : modelValue

폼에는 사용자 입력을 받을 수 있는 input 태그가 존재하며, 해당 input의 타입은 매우 다양합니다.
아마 지금까지 보지 못한 것들이 있을 수 있습니다.

  • button
  • checkbox
  • color
  • date
  • datetime-local
  • email
  • file
  • hidden
  • image
  • month
  • number
  • password
  • radio
  • range
  • reset
  • search
  • submit
  • tel
  • text
  • time
  • url
  • week

이 인풋들은 전부 사용자 입력을 받아 상태로 저장하는 역할을 수행하지만,
찾아보면 checkbox와 같이 독특한 속성(checked)를 사용하는 경우도 있으며,
실제로 인풋은 아니지만 인풋처럼 (textarea, select) 동작하는 경우가 있습니다.
이와 같이 특정 입력에 대해 특정 변수와 데이터, 상태를 동기화하는 것을 매우 쉽게 해주는 v-model이라는 개념이 있습니다.

v-model을 이용한 컴포넌트 디자인

 

Vue3 컴포넌트 디자인 패턴 : v-model

v-model을 이용하여 단순 상태 업데이트 관련 코드 상용구를 줄여봅니다. v-model의 사용사례 프런트엔드에서 양식을 처리할 때 양식 입력 요소의 상태를 JavaScript의 해당 상태와 동기화해야 하는 경

itchallenger.tistory.com

위 글을 읽어보면 아시겠지만, v-model은 단방향 데이터 플로우의 문법적 설탕에 불과하며,

모든 컴포넌트에 해당 로직을 일반화하여 적용할 수 있습니다.

 

따라서 모든 양식 내부의 입력 역할을 하는 컴포넌트에 대해 아래와 같은 작업을 수행합니다.

  • 라벨 추가
  • v-model 기능 추가
  • input에 해당하는 요소들을 리액티브하게 만들기

BaseCheckbox

사용자는 해당 변수가 checked에 바인딩 되는지, value에 바인딩 되는지 관계없이 v-model만 사용합니다.

<template>
  <input
    type="checkbox"
    :checked="modelValue"
    @change="$emit('update:modelValue', $event.target.checked)"
    class="field"
  />
  <label v-if="label">{{ label }}</label>
</template>

<script>
export default {
  props: {
    label: {
      type: String,
      default: ''
    },
    modelValue: {
      type: Boolean,
      default: false
    }
  }
}
</script>

BaseInput

가장 일반적인 사용자 입력을 위한 인풋입니다.

입력은 숫자가 될 수도 있고 문자가 될 수도 있습니다.

<template>
  <label :for="uuid" v-if="label">{{ label }}</label>
  <input
    v-bind="$attrs"
    :value="modelValue"
    :placeholder="label"
    @input="$emit('update:modelValue', $event.target.value)"
    class="field"
    :id="uuid"
    :aria-describedby="error ? `${uuid}-error` : null"
    :aria-invalid="error ? true : null"
  >
  <p
    v-if="error"
    class="errorMessage"
    :id="`${uuid}-error`"
    aria-live="assertive"
  >
    {{ error }}
  </p>
</template>

<script>
import UniqueID from '../features/UniqueID'
export default {
  props: {
    label: {
      type: String,
      default: ''
    },
    modelValue: {
      type: [String, Number],
      default: ''
    },
    error: {
      type: String,
      default: ''
    }
  },
  setup () {
    const uuid = UniqueID().getID()
    return {
      uuid
    }
  }
}
</script>

BaseRadio

사용자는 해당 변수가 checked에 바인딩 되는지, value에 바인딩 되는지 관계없이 v-model만 사용합니다.

<template>
  <input
      type="radio"
      :checked="modelValue === value"
      :value="value"
      v-bind="$attrs"
      @change="$emit('update:modelValue', value)"
    />
  <label v-if="label">{{ label }}</label>
</template>

<script>
export default {
  props: {
    label: {
      type: String,
      default: ''
    },
    modelValue: {
      type: [String, Number],
      default: ''
    },
    value: {
      type: [String, Number],
      required: true
    }
  }
}
</script>

BaseRadioGroup

이전의 BaseRadio 컴포넌트를 재활용 합니다.

수직, 수평으로 쌓는 옵션 prop(vertical)을 추가합니다.

<template>
  <component
    v-for="option in options"
    :key="option.value"
    :is="vertical ? 'div' : 'span'"
    :class="{
      'horizontal': !vertical
    }"
  >
    <BaseRadio
      :label="option.label"
      :value="option.value"
      :modelValue="modelValue"
      :name="name"
      @update:modelValue="$emit('update:modelValue', $event)"
    />
  </component>
</template>

<script>
export default {
  props: {
    options: {
      type: Array,
      required: true
    },
    name: {
      type: String,
      required: true
    },
    modelValue: {
      type: [String, Number],
      required: true
    },
    vertical: {
      type: Boolean,
      default: false
    }
  }
}
</script>

<style scoped>
.horizontal {
  margin-right: 20px;
}
</style>

props vs slot

위와 같이 option list를 데이터로 받아 렌더링 하는 것의 장점은

레이아웃(마크업)과 스타일 관심사를 list, group 컴포넌트로 추상화 할 수 있다는 것입니다.

단점은? 내부 컴포넌트의 레이아웃(마크업, 스타일)의 커스터마이징이 매우 어렵다는 점입니다.

 

해당 API를 사용하는 개발자가 마크업(레이아웃), 스타일 친화적인 개발자라면

UI 컴포넌트는 유연하게(slot 기반) 가져가는 것이 좋습니다. (ex : headlessui의 조합형 컴포넌트)

또한 wrapper 컴포넌트는 내부의 마크업 보다는 외부의 레이아웃(스타일, 마크업), 상태관리를 담당하면 좋습니다.

 

해당 API를 사용하는 개발자가 주로 데이터 바인딩, 인터랙션만 담당한다면 콜백과 이벤트 위주의 프롭만 제공하는 것이 좋습니다.

이 경우 API 사용자가 마크업(ex : 탭 수직 방향 설정하기)과 특점 돔 요소에 이벤트 바인딩을 직접 할 수 있도록 하는게 옳은가는 생각해 봐야 할 문제입니다.

prop으로 레이아웃(마크업), 스타일을 추상화 하게 되면 하단 컴포넌트의 참조를 전달하는 것이 어려워집니다.

 

prop만으로 완결된 컴포넌트를 제공할 수 있다면 best입니다.

 

하지만 만약 도메인, 비즈니스 로직 개발자가 ui에 직접 트랜지션, 포커싱 등의 효과를 줘야 한다면 ref로 해당 돔에 직접 접근하지 못하면 힘들 수 있습니다.

react의 imperativeHandle이나 vue의 expose, scoped slot을 활용하는 방법이 있습니다만, 컴포넌트가 수정되어야 합니다.

따라서 props을 이용한 구성과 slot을 이용한 마크업, 스타일의 노출 사이의 적절한 균형이 필요합니다.

BaseSelect

select는 별도의 태그가 존재합니다.

사용자는 해당 컴포넌트가 어떤 태그로 구현되어있는지 관계없이 사용할 수 있습니다.

 

<template>
  <label v-if="label">{{ label }}</label>
  <select
    class="field"
    :value="modelValue"
    v-bind="{
      ...$attrs,
      onChange: ($event) => { $emit('update:modelValue', $event.target.value) }
    }"
  >
    <option
      v-for="option in options"
      :value="option"
      :key="option"
      :selected="option === modelValue"
    >{{ option }}</option>
  </select>
</template>

<script>
export default {
  props: {
    label: {
      type: String,
      default: ''
    },
    modelValue: {
      type: [String, Number],
      default: ''
    },
    options: {
      type: Array,
      required: true
    }
  }
}
</script>

Putting all together

위에서 만든 컴포넌트들을 조합하면 아래와 같은 모양이 됩니다.

composition API로 바꾸는 것도 간단하니 option API인 상태로 두겠습니다.

<template>
  <div>
    <h1>Create an event</h1>
    <form @submit.prevent="sendForm">
      <BaseSelect
        :options="categories"
        v-model="event.category"
        label="Select a category"
      />

      <fieldset>
        <legend>Name & describe your event</legend>

        <BaseInput
          v-model="event.title"
          label="Title"
          type="text"
          error="This input has an error!"
        />

        <BaseInput
          v-model="event.description"
          label="Description"
          type="text"
        />
      </fieldset>

      <fieldset>
        <legend>Where is your event?</legend>

        <BaseInput
          v-model="event.location"
          label="Location"
          type="text"
        />
      </fieldset>

      <fieldset>
        <legend>Pets</legend>

        <p>Are pets allowed?</p>
        <div>
          <BaseRadioGroup
            v-model="event.pets"
            name="pets"
            :options="petOptions"
          />
        </div>
      </fieldset>

      <fieldset>
        <legend>Extras</legend>
        <div>
          <BaseCheckbox
            v-model="event.extras.catering"
            label="Catering"
          />
        </div>

        <div>
          <BaseCheckbox
            v-model="event.extras.music"
            label="Live music"
          />
        </div>
      </fieldset>

      <button type="submit">Submit</button>
    </form>

    <pre>{{ event }}</pre>
  </div>
</template>

<script>
import axios from 'axios'
export default {
  data () {
    return {
      categories: [
        'sustainability',
        'nature',
        'animal welfare',
        'housing',
        'education',
        'food',
        'community'
      ],
      event: {
        category: '',
        title: '',
        description: '',
        location: '',
        pets: 1,
        extras: {
          catering: false,
          music: false
        }
      },
      petOptions: [
        { label: 'Yes', value: 1 },
        { label: 'No', value: 0 }
      ]
    }
  },
  methods: {
    sendForm (e) {
      axios.post('https://my-json-server.typicode.com/Code-Pop/Vue-3-Forms/events', this.event)
        .then(function (response) {
          console.log('Response', response)
        })
        .catch(function (err) {
          console.log('Error', err)
        })
    }
  }
}
</script>

<style>
fieldset {
  border: 0;
  margin: 0;
  padding: 0;
}
legend {
  font-size: 28px;
  font-weight: 700;
  margin-top: 20px;
}
</style>

위 폼에서 설명되지 않는 내용들을 아래에서 추가로 논의하겠습니다.


Basic accesibility

접근성은 앱 개발을 완료한 후 수행해야 하는 부수적인 작업이 아닙니다.

개발 프로세스의 일부로 해결해야 하는 주요 관심사입니다.

적절한 type 지정

양식에는 전체 컨텍스트를 지배하는 가장 중요한 input이 존재합니다.

input은 type을 이용하여 다양한 형태로 전환이 가능합니다.

 

input 요소에서 특정 타입을 사용하면 양식의 자동 완성 기능이 향상될 뿐만 아니라
스크린 리더가 사용자가 검색하려는 데이터 타입을 더 잘 이해할 수 있습니다.
예를 들어 tel type은 휴대 전화 사용자에게 + * #와 같은 전화 기호가 있는 편리한 숫자 키보드를 제공합니다.
 
password, email이 아닌 경우에도 type을 입력하는 것을 잊지 마세요

Fieldset과 Legend

HTML에서 종종 간과되는 두 가지 요소는 fieldset과 legend입니다.

양식에서는 일반적으로 입력을 논리적으로 그룹화합니다.
예를 들어 일반적으로 사용자에게 이름, 성, 전화번호와 같은 개인 데이터를 먼저 요청하도록 양식을 코딩합니다.
나중에 다른 섹션에서 배송 주소를 요청할 수 있습니다.

 

항상 fieldset 요소 내에서 양식의 섹션을 래핑해야 합니다. 그러면 그 내부의 입력이 논리적으로 그룹화됩니다.
그런 다음 fieldset의 첫 번째 요소는 해당 특정 fieldset에 대한 제목을 제공하는 legend 요소가 됩니다.

어떤 이유로 양식에 legend를 표시하지 않으려는 경우(일반적으로 디자인상의 이유 때문에)
표시되는 화면 외부에 절대 위치를 지정할 수 있습니다.

📃 SimpleForm.vue

<template>
  <div>
    <h1>Create an event</h1>
    <form @submit.prevent="sendForm">
      <BaseSelect
        :options="categories"
        v-model="event.category"
        label="Select a category"
      />

      <fieldset>
        <legend>Name & describe your event</legend>

        <BaseInput
          v-model="event.title"
          label="Title"
          type="text"
        />

        <BaseInput
          v-model="event.description"
          label="Description"
          type="text"
        />
      </fieldset>

      <fieldset>
        <legend>Where is your event?</legend>

        <BaseInput
          v-model="event.location"
          label="Location"
          type="text"
        />
      </fieldset>

      <fieldset>
        <legend>Pets</legend>

        <p>Are pets allowed?</p>
        <div>
          <BaseRadioGroup
            v-model="event.pets"
            name="pets"
            :options="petOptions"
          />
        </div>
      </fieldset>

      <fieldset>
        <legend>Extras</legend>
        <div>
          <BaseCheckbox
            v-model="event.extras.catering"
            label="Catering"
          />
        </div>

        <div>
          <BaseCheckbox
            v-model="event.extras.music"
            label="Live music"
          />
        </div>
      </fieldset>

      <button type="submit">Submit</button>
    </form>

    <pre>{{ event }}</pre>
  </div>
</template>

fieldset과 legend에도 스타일을 지정할 수 있습니다.

<style>
fieldset {
  border: 0;
  margin: 0;
  padding: 0;
}

legend {
  font-size: 28px;
  font-weight: 700;
  margin-top: 20px;
}
</style>

FireFox의 접근성 도구를 사용해 봅시다.

접근성 탭을 확인하면 이제 스크린 리더가가 양식의 논리적 그룹화를 어떻게 이해하는지 확인할 수 있습니다.

플레이스홀더에 의존하지 않기

몇 년 전에 등장한 인기 있는 디자인 패턴은 요소가 기대하는 콘텐츠 타입을 설명하기 위해 input의 placeholder를 사용하는 것이었습니다.

플레이스홀더는 의도한 값을 설명하는 데만 사용해야 하며 설명 레이블을 대체해서는 안 됩니다.
  • 플레이스홀더는 사용자가 필드에 입력을 시작할 때마다 사라지므로 사용자는 해당 필드가 예상하는 내용을 기억해야 합니다.
  • 또한 일부 사용자는 플레이스홀더가 있는 필드와 콘텐츠가 미리 채워져있거나, 이미 채워진 필드를 구분하는 데 문제가 있을 수 있습니다.

각 스크린 리더는 플레이스홀더 속성을 다르게 취급할 수 있지만 올바르게 설정된 레이블이 있는 한 큰 문제가 되지 않습니다.

Labels

슬프게도 매우 일반적으로 양식에서 잘 사용되지 않거나 오용되는 정말 강력한 접근성 기능에 대해 이야기해 보겠습니다.
FireFox 접근성 탭으로 이동하여 Title 입력을 검사하면 바로 옆에 ⚠️ 아이콘이 표시됩니다.
이것은 우리에게 문제가 있음을 의미합니다.
인포메이션 패널을 살펴보겠습니다. Checks 섹션은 이미 "양식 요소에는 눈에 보이는 텍스트 레이블이 있어야 합니다"라는 문제를 알려줍니다.
Title 필드 위에 이 입력에 대한 의도를 설명하는 레이블이 분명히 있기 때문에 놀랄 수도 있습니다.

그러나 시력이 좋지 않은 사용자에게는 이것이 분명하지 않습니다.
우리는 아직 이 두 HTML 요소를 연결하지 않았으며 이는 스크린 리더가 만들 수 없는 가정입니다.

 

입력 요소를 레이블과 연결하는 몇 가지 방법이 있습니다.
첫 번째 방법은 레이블 요소 내부에 입력을 중첩하는 것입니다.

<label>
  Title
  <input />
</label>
이것은 입력이 관련 레이블에 항상 올바르게 연결되어 있는지 확인하는 가장 쉬운 방법 중 하나이지만,
몇몇 브라우저는 지원하지 않습니다.
 
따라서 HTML 요소를 관련시키는 두 번째이자 더 "일반적인" 방법을 활용합니다.
단순히 레이블과 인풋을 id로 연관시키는 방법입니다.
 
두 개를 하나의 컴포넌트로 만들고 자동으로 uuid를 동시에 바인딩하면, 사용자가 따로 속성으로 전달해 줄 필요가 없습니다.
 
📃 BaseInput.vue
<template>
  <label :for="uuid" v-if="label">{{ label }}</label>
  <input
    v-bind="$attrs"
    :value="modelValue"
    :placeholder="label"
    @input="$emit('update:modelValue', $event.target.value)"
    class="field"
    :id="uuid"
  >
</template>​
label 안의 텍스트 Titel에 의해 labelled by된 것을 알 수 있습니다.
컴포넌트 고유의 uuid가 할당되어 연관된 모습

접근 가능한 에러

폼 개발 시 자주 간과되는 또 하나의 요소입니다.
바로 에러 메세지와의 연계입니다. 

양식 제출 시 오류가 발생했는데 오류 관련 메세지가 나타나지 않는 것을 상상해 보세요.
스크린 리더를 사용하는 사용자도 동일합니다.
해당 에러에 대한 정보를 제공해야 합니다.
 
방법은 아주 간단합니다. 아래와 같이 id를 이용해 `${id}-error` 형태로 연계해줍니다.

📃 BaseInput.vue

<p
  v-if="error"
  class="errorMessage"
  :id="`${uuid}-error`"
>
  {{ error }}
</p>
described by로 연관된 정보

해당 정보는 조건부로 화면에 나타나기 떄문에, 변경 시마다 스크린 리더가 독자에게 알려줄 수 있도록 해야 합니다.

  • aria-live="assertive" 속성 추가(추천)
  • role="alert" 추가

📃 BaseInput.vue

<p
  v-if="error"
  class="errorMessage"
  :id="`${uuid}-error`"
  aria-live="assertive"
>
  {{ error }}
</p>

 

명시적인 입력 상태

보통 많은 양식은 유효하지 않은 입력 주변에 빨간색 테두리를 추가합니다.
시각을 사용할 수 없는 사용자는 해당 정보를 판단할 수 없습니다.

사용자에게 더 나은 피드백을 제공하기 위해 잘못된 입력 상태에 대해서도 스크린 리더에 알려야 합니다.

 

aria-invalid 속성을 입력에 추가하고 error props 값에 따라 켜고 끕니다.

입력이 유효하면 해당 속성이 입력 요소에 추가되지 않도록 null이 됩니다.

<input
  v-bind="$attrs"
  :value="modelValue"
  :placeholder="label"
  @input="$emit('update:modelValue', $event.target.value)"
  class="field"
  :id="uuid"
  :aria-describedby="error ? `${uuid}-error` : null"
  :aria-invalid="error ? true : null"
>

브라우저로 돌아가 Firefox의 접근성 도구를 사용하여 input을 검사하면
input의 활성 상태에 "유효하지 않음" 상태가 추가되었음을 알 수 있습니다.

입력의 활성 상태에 "유효하지 않음" 상태가 추가되었음을 알 수 있습니다.
 
다른 주목할만한 추가 가능한 상태는 읽기 전용, 비활성화 및 필수입니다.
이 세 가지 속성은 동일한 이름의 HTML5 속성 또는 aria 대응 항목(aria-readonly, aria-disabled 및 aria-required)을 사용하여
직접 설정할 수 있습니다.

제출 버튼을 비활성화 하지 마세요

양식이 유효하지 않은 경우 제출 버튼의 disabled 속성을 true로 설정하여 사용자가 양식을 제출할 수 없도록 하는 것이 맞나요?
클릭할 수 없다는 것을 전달하기 위해 다른 색상으로 버튼의 스타일을 지정할 수도 있습니다.
 

여기에는 큰 문제가 있습니다.

스크린 리더에 의존하는 사용자가 양식 내부를 탭으로 이동할 때 스크린 리더는 버튼을 완전히 무시합니다.

이 경우 사용자는 버튼을 발견하지 못할 수 있으며,이것은 분명히 매우 혼란스러울 수 있습니다. 

 

제출 버튼을 비활성화 하는 대신 대신 제출 버튼 클릭 시 양식이 유효한지 확인하기 위해 모든 검사를 수행하는 것이 좋습니다.

모든 것이 정상이면 양식을 정상적으로 제출합니다.

 

문제가 있는 경우 방금 배운 방법을 사용하여 양식에 필요한 오류를 설정합니다.


참고

https://www.vuemastery.com/courses/vue3-forms/forms-introduction

 

Introduction - Vue 3 Forms | Vue Mastery

Find out what we’ll cover in this form fundamentals course and what knowledge you’ll need to get the most out of it.

www.vuemastery.com

 

더 강력한 form 만들기 : vee-validate

 

GitHub - logaretm/vee-validate: ✅ Painless Vue forms

✅ Painless Vue forms. Contribute to logaretm/vee-validate development by creating an account on GitHub.

github.com

 

 

반응형