Development Tip

탭 인덱스에서 다음 요소에 초점 맞추기

yourdevel 2020. 10. 7. 21:18
반응형

탭 인덱스에서 다음 요소에 초점 맞추기


포커스가있는 현재 요소를 기반으로 탭 시퀀스의 다음 요소로 포커스를 이동하려고합니다. 지금까지 검색에서 아무것도 찾지 못했습니다.

function OnFocusOut()
{
    var currentElement = $get(currentElementId); // ID set by OnFocusIn 

    currentElementId = "";
    currentElement.nextElementByTabIndex.focus();
}

물론 nextElementByTabIndex가 이것이 작동하는 핵심 부분입니다. 탭 시퀀스에서 다음 요소를 어떻게 찾습니까? 솔루션은 JQuery와 같은 것이 아니라 JScript를 기반으로해야합니다.


jquery없이 : 우선, 탭 가능한 요소에 class="tabable"이것을 추가 하면 나중에 선택할 수 있습니다. (아래 코드에서 "."클래스 선택기 접두사를 잊지 마십시오)

var lastTabIndex = 10;
function OnFocusOut()
{
    var currentElement = $get(currentElementId); // ID set by OnFOcusIn
    var curIndex = currentElement.tabIndex; //get current elements tab index
    if(curIndex == lastTabIndex) { //if we are on the last tabindex, go back to the beginning
        curIndex = 0;
    }
    var tabbables = document.querySelectorAll(".tabable"); //get all tabable elements
    for(var i=0; i<tabbables.length; i++) { //loop through each element
        if(tabbables[i].tabIndex == (curIndex+1)) { //check the tabindex to see if it's the element we want
            tabbables[i].focus(); //if it's the one we want, focus it and exit the loop
            break;
        }
    }
}

나는 이것을 구현 한 적이 없지만 비슷한 문제를 조사했으며 여기에 내가 시도 할 것입니다.

먼저 시도

당신은 단순히 수 있다면 첫째, 나는 볼 것이다 화재 keypress이벤트를 현재 포커스가있는 요소에 탭 키를. 브라우저마다이 작업을 수행하는 다른 방법이있을 수 있습니다.

그래도 안된다면 더 열심히해야합니다 ...

jQuery 구현을 참조하려면 다음을 수행해야합니다.

  1. Tab 및 Shift + Tab 듣기
  2. 탭 가능한 요소 파악
  3. 탭 순서 작동 방식 이해

1. Tab 및 Shift + Tab 듣기

Tab 및 Shift + Tab 듣기는 웹의 다른 곳에서 잘 다루어 질 수 있으므로 해당 부분을 건너 뛰겠습니다.

2. 탭 가능한 요소 파악

탭 가능한 요소를 아는 것은 더 까다 롭습니다. 기본적으로 요소는 포커스가 있고 속성이 tabindex="-1"설정 되지 않은 경우 탭이 가능합니다 . 따라서 어떤 요소가 초점을 맞출 수 있는지 물어봐야합니다. 다음 요소에 초점을 맞출 수 있습니다.

  • input, select, textarea, button, 및 object요소 해제되지 않은.
  • a세트에 대한 숫자 값이 있거나있는 area요소 .hreftabindex
  • tabindex세트에 대한 숫자 값이있는 모든 요소 .

또한 다음과 같은 경우에만 요소에 초점을 맞출 수 있습니다.

  • 그 조상은 없습니다 display: none.
  • 의 계산 된 값은 visibility입니다 visible. 즉, visibility설정 한 가장 가까운 조상 은 값이이어야합니다 visible. 조상이 visibility설정 되지 않은 경우 계산 된 값은 visible입니다.

자세한 내용은 다른 Stack Overflow 답변에 있습니다.

3. 탭 순서 작동 방식 이해

문서에있는 요소의 탭 순서는 tabindex속성에 의해 제어됩니다 . 값이 설정되지 않은 경우은 tabindex사실상 0입니다.

tabindex문서 순서는 1, 2, 3,…, 0입니다.

처음에 body요소 (또는 요소 없음)에 포커스가 있을 때 탭 순서의 첫 번째 요소는 0이 아닌 가장 낮은 요소입니다 tabindex. 여러 요소가 동일한 tabindex경우 마지막 요소에 도달 할 때까지 문서 순서대로 이동합니다 tabindex. 그런 다음 다음으로 낮은 값으로 이동 tabindex하고 프로세스가 계속됩니다. 마지막으로 0 (또는 비어 있음)으로 해당 요소로 마무리 tabindex합니다.


이 목적을 위해 내가 만든 것입니다.

focusNextElement: function () {
    //add all elements we want to include in our selection
    var focussableElements = 'a:not([disabled]), button:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])';
    if (document.activeElement && document.activeElement.form) {
        var focussable = Array.prototype.filter.call(document.activeElement.form.querySelectorAll(focussableElements),
        function (element) {
            //check for visibility while always include the current activeElement 
            return element.offsetWidth > 0 || element.offsetHeight > 0 || element === document.activeElement
        });
        var index = focussable.indexOf(document.activeElement);
        if(index > -1) {
           var nextElement = focussable[index + 1] || focussable[0];
           nextElement.focus();
        }                    
    }
}

풍모:

  • 구성 가능한 포커스 가능 요소 집합
  • jQuery가 필요하지 않습니다.
  • 모든 최신 브라우저에서 작동
  • 빠르고 가벼운

이 작업을 수행하는 간단한 jQuery 플러그인만들었습니다 . jQuery UI의 ': tabbable'선택기를 사용하여 다음 'tabbable'요소를 찾아 선택합니다.

사용 예 :

// Simulate tab key when element is clicked 
$('.myElement').bind('click', function(event){
    $.tabNext();
    return false;
});

대답의 핵심은 다음 요소를 찾는 데 있습니다.

  function findNextTabStop(el) {
    var universe = document.querySelectorAll('input, button, select, textarea, a[href]');
    var list = Array.prototype.filter.call(universe, function(item) {return item.tabIndex >= "0"});
    var index = list.indexOf(el);
    return list[index + 1] || list[0];
  }

용법:

var nextEl = findNextTabStop(element);
nextEl.focus();

우선 순위를 지정하는 데 관심이 없습니다 tabIndex.


위의 주석에서 언급했듯이 모든 브라우저가 탭 순서 정보를 노출한다고 생각하지 않습니다. 다음은 탭 순서에서 다음 요소를 가져 오기 위해 브라우저가 수행하는 작업의 단순화 된 근사치입니다.

var allowedTags = {input: true, textarea: true, button: true};

var walker = document.createTreeWalker(
  document.body,
  NodeFilter.SHOW_ELEMENT,
  {
    acceptNode: function(node)
    {
      if (node.localName in allowedTags)
        return NodeFilter.FILTER_ACCEPT;
      else
        NodeFilter.FILTER_SKIP;
    }
  },
  false
);
walker.currentNode = currentElement;
if (!walker.nextNode())
{
  // Restart search from the start of the document
  walker.currentNode = walker.root;
  walker.nextNode();
}
if (walker.currentNode && walker.currentNode != walker.root)
  walker.currentNode.focus();

이것은 일부 태그 만 고려하고 tabindex속성을 무시 하지만 달성하려는 것에 따라 충분할 수 있습니다.


tabIndex요소 속성을 확인하여 초점을 맞출 수 있는지 확인할 수있는 것 같습니다 . 초점을 맞출 수없는 요소 tabindex는 "-1"입니다.

그런 다음 탭 중지에 대한 규칙을 알아야합니다.

  • tabIndex="1" 우선 순위가 가장 높습니다.
  • tabIndex="2" 다음으로 높은 우선 순위가 있습니다.
  • tabIndex="3" 다음입니다.
  • tabIndex="0" (또는 기본적으로 탭 가능) 우선 순위가 가장 낮습니다.
  • tabIndex="-1" (또는 기본적으로 탭할 수 없음)은 탭 정지 역할을하지 않습니다.
  • tabIndex가 동일한 두 요소의 경우 DOM에서 가장 먼저 나타나는 요소가 더 높은 우선 순위를 갖습니다.

다음은 순수 자바 스크립트를 사용하여 순서대로 탭 정지 목록을 작성하는 방법의 예입니다.

function getTabStops(o, a, el) {
    // Check if this element is a tab stop
    if (el.tabIndex > 0) {
        if (o[el.tabIndex]) {
            o[el.tabIndex].push(el);
        } else {
            o[el.tabIndex] = [el];
        }
    } else if (el.tabIndex === 0) {
        // Tab index "0" comes last so we accumulate it seperately
        a.push(el);
    }
    // Check if children are tab stops
    for (var i = 0, l = el.children.length; i < l; i++) {
        getTabStops(o, a, el.children[i]);
    }
}

var o = [],
    a = [],
    stops = [],
    active = document.activeElement;

getTabStops(o, a, document.body);

// Use simple loops for maximum browser support
for (var i = 0, l = o.length; i < l; i++) {
    if (o[i]) {
        for (var j = 0, m = o[i].length; j < m; j++) {
            stops.push(o[i][j]);
        }
    }
}
for (var i = 0, l = a.length; i < l; i++) {
    stops.push(a[i]);
}

먼저 DOM을 살펴보고 인덱스와 함께 모든 탭 정지를 순서대로 수집합니다. 그런 다음 최종 목록을 조합합니다. 우리가 항목을 추가하는 것이 공지 tabIndex="0"로모그래퍼 항목 후, 목록의 맨 끝에 tabIndex1, 2, 3 등

"enter"키를 사용하여 탭할 수있는 완전히 작동하는 예제를 보려면이 fiddle을 확인하십시오 .


위의 솔루션을 확인한 결과 상당히 길었습니다. 한 줄의 코드로 수행 할 수 있습니다.

currentElement.nextElementSibling.focus();

또는

currentElement.previousElementSibling.focus();

여기서 currentElement는 임의의 즉 document.activeElement이거나 현재 요소가 함수의 컨텍스트에있는 경우 this 일 수 있습니다.

keydown 이벤트로 탭 및 시프트 탭 이벤트를 추적했습니다.

let cursorDirection = ''
$(document).keydown(function (e) {
    let key = e.which || e.keyCode;
    if (e.shiftKey) {
        //does not matter if user has pressed tab key or not.
        //If it matters for you then compare it with 9
        cursorDirection = 'prev';
    }
    else if (key == 9) {
        //if tab key is pressed then move next.
        cursorDirection = 'next';
    }
    else {
        cursorDirection == '';
    }
});

커서 방향이 있으면 nextElementSibling.focus또는 previousElementSibling.focus방법을 사용할 수 있습니다.


Tabbable는 당신에게 모든 tabbable 요소의 목록 제공하는 작은 JS 패키지 탭 순서를 . 따라서 해당 목록에서 요소를 찾은 다음 다음 목록 항목에 집중할 수 있습니다.

패키지는 다른 답변에서 언급 된 복잡한 엣지 케이스를 올바르게 처리합니다 (예 : 조상이 될 수 없음 display: none). 그리고 그것은 jQuery에 의존하지 않습니다!

이 글을 쓰는 시점 (버전 1.1.1)에서는 IE8을 지원하지 않으며 브라우저 버그로 인해 contenteditable올바르게 처리되지 않는다는 경고가 있습니다 .


이것은 SO에 대한 첫 번째 게시물이므로 수락 된 답변에 대해 충분한 평판을 얻지 못했지만 코드를 다음과 같이 수정해야했습니다.

export function focusNextElement () {
  //add all elements we want to include in our selection
  const focussableElements = 
    'a:not([disabled]), button:not([disabled]), input[type=text]:not([disabled])'
  if (document.activeElement && document.activeElement.form) {
      var focussable = Array.prototype.filter.call(
        document.activeElement.form.querySelectorAll(focussableElements),
      function (element) {
          // if element has tabindex = -1, it is not focussable
          if ( element.hasAttribute('tabindex') && element.tabIndex === -1 ){
            return false
          }
          //check for visibility while always include the current activeElement 
          return (element.offsetWidth > 0 || element.offsetHeight > 0 || 
            element === document.activeElement)
      });
      console.log(focussable)
      var index = focussable.indexOf(document.activeElement);
      if(index > -1) {
         var nextElement = focussable[index + 1] || focussable[0];
         console.log(nextElement)
         nextElement.focus()
      }                    
  }
}

var를 상수로 변경하는 것은 중요하지 않습니다. 주요 변경 사항은 tabindex! = "-1"을 확인하는 선택기를 제거한다는 것입니다. 그런 다음 나중에 요소에 tabindex 속성이 있고 "-1"로 설정되어 있으면 포커스 가능하다고 간주하지 않습니다.

이것을 변경해야하는 이유는 tabindex = "-1"을에 추가 할 <input>때이 요소가 "input [type = text] : not ([disabled])"선택기와 일치하기 때문에 여전히 포커스 가능한 것으로 간주 되었기 때문입니다. 내 변경은 "비활성화 된 텍스트 입력이고 tabIndex 속성이 있고 해당 속성의 값이 -1 인 경우 포커스 가능으로 간주되지 않아야합니다.

나는 수락 된 답변의 작성자가 tabIndex 속성을 설명하기 위해 답변을 편집했을 때 올바르게 수정하지 않았다고 생각합니다. 그렇지 않은 경우 알려주십시오.


순환하려는 각 요소에 대해 고유 한 tabIndex 값을 지정 했습니까? 그렇다면 다음을 시도해 볼 수 있습니다.

var lasTabIndex = 10; //Set this to the highest tabIndex you have
function OnFocusOut()
{
    var currentElement = $get(currentElementId); // ID set by OnFocusIn 

    var curIndex = $(currentElement).attr('tabindex'); //get the tab index of the current element
    if(curIndex == lastTabIndex) { //if we are on the last tabindex, go back to the beginning
        curIndex = 0;
    }
    $('[tabindex=' + (curIndex + 1) + ']').focus(); //set focus on the element that has a tab index one greater than the current tab index
}

jquery를 사용하고 있습니까?


이것이 도움이되기를 바랍니다.

<input size="2" tabindex="1" id="one"
  maxlength="2" onkeyup="toUnicode(this)" />

<input size="2" tabindex="2" id="two"
  maxlength="2" onkeyup="toUnicode(this)" />

<input size="2" tabindex="3" id="three"
 maxlength="2" onkeyup="toUnicode(this)" />

그런 다음 간단한 자바 스크립트를 사용하십시오.

function toUnicode(elmnt)
{
  var next;
 if (elmnt.value.length==elmnt.maxLength)
{
next=elmnt.tabIndex + 1;
//look for the fields with the next tabIndex
var f = elmnt.form;
for (var i = 0; i < f.elements.length; i++)
{
  if (next<=f.elements[i].tabIndex)
  {
    f.elements[i].focus();
    break;
    }
   }
  }
}

다음 요소에 초점을 맞춘보다 완전한 버전입니다. 사양 지침을 따르고 tabindex를 사용하여 요소 목록을 올바르게 정렬합니다. 또한 이전 요소를 얻으려면 반전 변수가 정의됩니다.

function focusNextElement( reverse, activeElem ) {
  /*check if an element is defined or use activeElement*/
  activeElem = activeElem instanceof HTMLElement ? activeElem : document.activeElement;

  let queryString = [
      'a:not([disabled]):not([tabindex="-1"])',
      'button:not([disabled]):not([tabindex="-1"])',
      'input:not([disabled]):not([tabindex="-1"])',
      'select:not([disabled]):not([tabindex="-1"])',
      '[tabindex]:not([disabled]):not([tabindex="-1"])'
      /* add custom queries here */
    ].join(','),
    queryResult = Array.prototype.filter.call(document.querySelectorAll(queryString), elem => {
      /*check for visibility while always include the current activeElement*/
      return elem.offsetWidth > 0 || elem.offsetHeight > 0 || elem === activeElem;
    }),
    indexedList = queryResult.slice().filter(elem => {
      /* filter out all indexes not greater than 0 */
      return elem.tabIndex == 0 || elem.tabIndex == -1 ? false : true;
    }).sort((a, b) => {
      /* sort the array by index from smallest to largest */
      return a.tabIndex != 0 && b.tabIndex != 0 
        ? (a.tabIndex < b.tabIndex ? -1 : b.tabIndex < a.tabIndex ? 1 : 0) 
        : a.tabIndex != 0 ? -1 : b.tabIndex != 0 ? 1 : 0;
    }),
    focusable = [].concat(indexedList, queryResult.filter(elem => {
      /* filter out all indexes above 0 */
      return elem.tabIndex == 0 || elem.tabIndex == -1 ? true : false;
    }));

  /* if reverse is true return the previous focusable element
     if reverse is false return the next focusable element */
  return reverse ? (focusable[focusable.indexOf(activeElem) - 1] || focusable[focusable.length - 1]) 
    : (focusable[focusable.indexOf(activeElem) + 1] || focusable[0]);
}

이것은 @Kano@Mx가 제공 한 훌륭한 솔루션에 대한 잠재적 인 향상 입니다. TabIndex 순서를 유지하려면 중간에 다음 정렬을 추가하십시오.

// Sort by explicit Tab Index, if any
var sort_by_TabIndex = function (elementA, elementB) {
    let a = elementA.tabIndex || 1;
    let b = elementB.tabIndex || 1;
    if (a < b) { return -1; }
    if (a > b) { return 1; }
    return 0;
}
focussable.sort(sort_by_TabIndex);

이것을 호출 할 수 있습니다.

탭:

$.tabNext();

Shift + Tab :

$.tabPrev();

<!DOCTYPE html>
<html>
<body>
<script src="https://code.jquery.com/jquery-3.3.1.js" integrity="sha256-2Kok7MbOyxpgUVvAk/HJ2jigOSYS2auK4Pfzbm7uH60=" crossorigin="anonymous"></script>
<script>
(function($){
	'use strict';

	/**
	 * Focusses the next :focusable element. Elements with tabindex=-1 are focusable, but not tabable.
	 * Does not take into account that the taborder might be different as the :tabbable elements order
	 * (which happens when using tabindexes which are greater than 0).
	 */
	$.focusNext = function(){
		selectNextTabbableOrFocusable(':focusable');
	};

	/**
	 * Focusses the previous :focusable element. Elements with tabindex=-1 are focusable, but not tabable.
	 * Does not take into account that the taborder might be different as the :tabbable elements order
	 * (which happens when using tabindexes which are greater than 0).
	 */
	$.focusPrev = function(){
		selectPrevTabbableOrFocusable(':focusable');
	};

	/**
	 * Focusses the next :tabable element.
	 * Does not take into account that the taborder might be different as the :tabbable elements order
	 * (which happens when using tabindexes which are greater than 0).
	 */
	$.tabNext = function(){
		selectNextTabbableOrFocusable(':tabbable');
	};

	/**
	 * Focusses the previous :tabbable element
	 * Does not take into account that the taborder might be different as the :tabbable elements order
	 * (which happens when using tabindexes which are greater than 0).
	 */
	$.tabPrev = function(){
		selectPrevTabbableOrFocusable(':tabbable');
	};

    function tabIndexToInt(tabIndex){
        var tabIndexInded = parseInt(tabIndex);
        if(isNaN(tabIndexInded)){
            return 0;
        }else{
            return tabIndexInded;
        }
    }

    function getTabIndexList(elements){
        var list = [];
        for(var i=0; i<elements.length; i++){
            list.push(tabIndexToInt(elements.eq(i).attr("tabIndex")));
        }
        return list;
    }

    function selectNextTabbableOrFocusable(selector){
        var selectables = $(selector);
        var current = $(':focus');

        // Find same TabIndex of remainder element
        var currentIndex = selectables.index(current);
        var currentTabIndex = tabIndexToInt(current.attr("tabIndex"));
        for(var i=currentIndex+1; i<selectables.length; i++){
            if(tabIndexToInt(selectables.eq(i).attr("tabIndex")) === currentTabIndex){
                selectables.eq(i).focus();
                return;
            }
        }

        // Check is last TabIndex
        var tabIndexList = getTabIndexList(selectables).sort(function(a, b){return a-b});
        if(currentTabIndex === tabIndexList[tabIndexList.length-1]){
            currentTabIndex = -1;// Starting from 0
        }

        // Find next TabIndex of all element
        var nextTabIndex = tabIndexList.find(function(element){return currentTabIndex<element;});
        for(var i=0; i<selectables.length; i++){
            if(tabIndexToInt(selectables.eq(i).attr("tabIndex")) === nextTabIndex){
                selectables.eq(i).focus();
                return;
            }
        }
    }

	function selectPrevTabbableOrFocusable(selector){
		var selectables = $(selector);
		var current = $(':focus');

		// Find same TabIndex of remainder element
        var currentIndex = selectables.index(current);
        var currentTabIndex = tabIndexToInt(current.attr("tabIndex"));
        for(var i=currentIndex-1; 0<=i; i--){
            if(tabIndexToInt(selectables.eq(i).attr("tabIndex")) === currentTabIndex){
                selectables.eq(i).focus();
                return;
            }
        }

        // Check is last TabIndex
        var tabIndexList = getTabIndexList(selectables).sort(function(a, b){return b-a});
        if(currentTabIndex <= tabIndexList[tabIndexList.length-1]){
            currentTabIndex = tabIndexList[0]+1;// Starting from max
        }

        // Find prev TabIndex of all element
        var prevTabIndex = tabIndexList.find(function(element){return element<currentTabIndex;});
        for(var i=selectables.length-1; 0<=i; i--){
            if(tabIndexToInt(selectables.eq(i).attr("tabIndex")) === prevTabIndex){
                selectables.eq(i).focus();
                return;
            }
        }
	}

	/**
	 * :focusable and :tabbable, both taken from jQuery UI Core
	 */
	$.extend($.expr[ ':' ], {
		data: $.expr.createPseudo ?
			$.expr.createPseudo(function(dataName){
				return function(elem){
					return !!$.data(elem, dataName);
				};
			}) :
			// support: jQuery <1.8
			function(elem, i, match){
				return !!$.data(elem, match[ 3 ]);
			},

		focusable: function(element){
			return focusable(element, !isNaN($.attr(element, 'tabindex')));
		},

		tabbable: function(element){
			var tabIndex = $.attr(element, 'tabindex'),
				isTabIndexNaN = isNaN(tabIndex);
			return ( isTabIndexNaN || tabIndex >= 0 ) && focusable(element, !isTabIndexNaN);
		}
	});

	/**
	 * focussable function, taken from jQuery UI Core
	 * @param element
	 * @returns {*}
	 */
	function focusable(element){
		var map, mapName, img,
			nodeName = element.nodeName.toLowerCase(),
			isTabIndexNotNaN = !isNaN($.attr(element, 'tabindex'));
		if('area' === nodeName){
			map = element.parentNode;
			mapName = map.name;
			if(!element.href || !mapName || map.nodeName.toLowerCase() !== 'map'){
				return false;
			}
			img = $('img[usemap=#' + mapName + ']')[0];
			return !!img && visible(img);
		}
		return ( /^(input|select|textarea|button|object)$/.test(nodeName) ?
			!element.disabled :
			'a' === nodeName ?
				element.href || isTabIndexNotNaN :
				isTabIndexNotNaN) &&
			// the element and all of its ancestors must be visible
			visible(element);

		function visible(element){
			return $.expr.filters.visible(element) && !$(element).parents().addBack().filter(function(){
				return $.css(this, 'visibility') === 'hidden';
			}).length;
		}
	}
})(jQuery);
</script>

<a tabindex="5">5</a><br>
<a tabindex="20">20</a><br>
<a tabindex="3">3</a><br>
<a tabindex="7">7</a><br>
<a tabindex="20">20</a><br>
<a tabindex="0">0</a><br>

<script>
var timer;
function tab(){
    window.clearTimeout(timer)
    timer = window.setInterval(function(){$.tabNext();}, 1000);
}
function shiftTab(){
    window.clearTimeout(timer)
    timer = window.setInterval(function(){$.tabPrev();}, 1000);
}
</script>
<button tabindex="-1" onclick="tab()">Tab</button>
<button tabindex="-1" onclick="shiftTab()">Shift+Tab</button>

</body>
</html>

완료하려면 jquery.tabbable 플러그인을 수정 합니다.


function focusNextElement(){
  var focusable = [].slice.call(document.querySelectorAll("a, button, input, select, textarea, [tabindex], [contenteditable]")).filter(function($e){
    if($e.disabled || ($e.getAttribute("tabindex") && parseInt($e.getAttribute("tabindex"))<0)) return false;
    return true;
  }).sort(function($a, $b){
    return (parseFloat($a.getAttribute("tabindex") || 99999) || 99999) - (parseFloat($b.getAttribute("tabindex") || 99999) || 99999);
  });
  var focusIndex = focusable.indexOf(document.activeElement);
  if(focusable[focusIndex+1]) focusable[focusIndex+1].focus();
};

참고 URL : https://stackoverflow.com/questions/7208161/focus-next-element-in-tab-index

반응형