미디어위키:Common.js: 두 판 사이의 차이

편집 요약 없음
편집 요약 없음
태그: 되돌려진 기여
1번째 줄: 1번째 줄:
/**
/**
  * MediaWiki 커스텀 펼치기/접기 애니메이션
  * MediaWiki custom toggle 애니메이션
  *
  *
  * 지원 예:
  * 예:
  *
  *
  * .mw-customtoggle-KSC_LEADER
  * mw-customtoggle-MALE
  *         ↕
  *     ↕
  * .mw-customcollapsible-KSC_LEADER
  * mw-customcollapsible-MALE
  *
  *
  * 식별자 이름은 자유롭게 사용 가능.
  * mw-customtoggle-FEMALE
*      ↕
* mw-customcollapsible-FEMALE
*
* 여러 개를 동시에 사용해도 각각 독립적으로 작동합니다.
  */
  */


18번째 줄: 22번째 줄:


/**
/**
* 토글 요소에서 식별자 추출
* mw-customtoggle-XXX 에서 XXX 추출
*
* 예:
* mw-customtoggle-KSC_LEADER
* → KSC_LEADER
*/
*/
function getToggleName( toggle ) {
function getToggleName( toggle ) {
39번째 줄: 39번째 줄:


/**
/**
* CSS selector escape
* CSS 선택자 escape
*/
*/
function escapeSelector( value ) {
function escapeSelector( value ) {
53번째 줄: 53번째 줄:


/**
/**
* 식별자에 대응하는 collapsible 대상 찾기
* 해당 이름에 연결된 모든 대상 찾기
*/
*/
function getTarget( name ) {
function getTargets( name ) {
if ( !name ) {
if ( !name ) {
return null;
return [];
}
}


var target = document.querySelector(
var selector =
'.mw-customcollapsible-' +
'.mw-customcollapsible-' +
escapeSelector( name )
escapeSelector( name );
);


if ( target ) {
var targets =
return target;
Array.prototype.slice.call(
}
document.querySelectorAll( selector )
);


target = document.getElementById(
/*
* id 형식도 지원
*/
var idTarget = document.getElementById(
'mw-customcollapsible-' + name
'mw-customcollapsible-' + name
);
);


return target;
if (
idTarget &&
targets.indexOf( idTarget ) === -1
) {
targets.push( idTarget );
}
 
return targets;
}
}


/**
/**
* 실제 애니메이션을 적용할 콘텐츠 찾기
* 실제 애니메이션 대상 결정
*
* 중요:
* 토글 버튼 자체가 들어있는 요소 전체를
* display:none으로 만들지 않도록 한다.
*/
*/
function getContent( target ) {
function getContent( target, toggle ) {
var content = target.querySelector(
var content = target.querySelector(
'.mw-collapsible-content'
'.mw-collapsible-content'
88번째 줄: 102번째 줄:
}
}


return target;
/*
* target 자체가 toggle을 포함하지 않는다면
* target 자체를 접어도 안전하다.
*/
if ( !target.contains( toggle ) ) {
return target;
}
 
/*
* toggle이 target 내부에 있다면
* target 전체를 숨기면 버튼까지 사라지므로
* 애니메이션하지 않는다.
*/
return null;
}
}


/**
/**
* 요소 원래 display 타입 보존
* 태그의 자연스러운 display 값
*
* table 요소에 display:block을 강제하면
* 레이아웃이 깨질 수 있으므로 태그별 기본값 사용.
*/
*/
function getNaturalDisplay( element ) {
function getNaturalDisplay( element ) {
136번째 줄: 160번째 줄:


/**
/**
* 현재 접혀있는지 확인
* 접힌 상태인지 확인
*/
*/
function isCollapsed( target, content ) {
function isCollapsed( target, content ) {
143번째 줄: 167번째 줄:
}
}


return window.getComputedStyle( content ).display === 'none';
return (
window.getComputedStyle( content ).display ===
'none'
);
}
}


149번째 줄: 176번째 줄:
* 펼치기
* 펼치기
*/
*/
function expand( target, content, toggle ) {
function expand( target, content ) {
if ( content.classList.contains( ANIMATING_CLASS ) ) {
if (
content.classList.contains(
ANIMATING_CLASS
)
) {
return;
return;
}
}


content.classList.add( ANIMATING_CLASS );
content.classList.add(
ANIMATING_CLASS
);
 
target.classList.remove(
'mw-collapsed'
);


target.classList.remove( 'mw-collapsed' );
content.style.display =
getNaturalDisplay( content );


/*
* display:none 상태에서는 높이를 구할 수 없으므로
* 원래 요소 유형에 맞는 display로 임시 복원
*/
content.style.display = getNaturalDisplay( content );
content.style.height = 'auto';
content.style.height = 'auto';
content.style.overflow = 'hidden';
content.style.overflow = 'hidden';


var targetHeight = content.scrollHeight;
var targetHeight =
content.scrollHeight;


/*
* 0 높이에서 시작
*/
content.style.height = '0px';
content.style.height = '0px';


/*
/*
* 강제 reflow
* reflow
*/
*/
content.offsetHeight;
content.offsetHeight;


var animation = content.animate(
var animation =
[
content.animate(
{
[
height: '0px'
{
},
height: '0px'
},
{
height:
targetHeight + 'px'
}
],
{
{
height: targetHeight + 'px'
duration: DURATION,
easing: 'ease',
fill: 'forwards'
}
}
],
);
{
duration: DURATION,
easing: 'ease',
fill: 'forwards'
}
);


animation.onfinish = function () {
animation.onfinish = function () {
content.classList.remove( ANIMATING_CLASS );
content.classList.remove(
ANIMATING_CLASS
);


/*
/*
* 원래 CSS가 다시 레이아웃을 결정하도록
* 애니메이션 후 원래 CSS 상태로 복구
* 인라인 스타일 제거
*/
*/
content.style.display = '';
content.style.display = '';
content.style.height = '';
content.style.height = '';
content.style.overflow = '';
content.style.overflow = '';
toggle.setAttribute(
'aria-expanded',
'true'
);


animation.cancel();
animation.cancel();
};
animation.oncancel = function () {
content.classList.remove( ANIMATING_CLASS );
};
};
}
}
221번째 줄: 246번째 줄:
* 접기
* 접기
*/
*/
function collapse( target, content, toggle ) {
function collapse( target, content ) {
if ( content.classList.contains( ANIMATING_CLASS ) ) {
if (
content.classList.contains(
ANIMATING_CLASS
)
) {
return;
return;
}
}


content.classList.add( ANIMATING_CLASS );
content.classList.add(
ANIMATING_CLASS
);


/*
* 현재 높이 측정
*/
var startHeight =
var startHeight =
content.getBoundingClientRect().height;
content.getBoundingClientRect()
.height;
 
content.style.display =
getNaturalDisplay( content );
 
content.style.height =
startHeight + 'px';


/*
content.style.overflow =
* 애니메이션 동안 display 타입 유지
'hidden';
*/
content.style.display = getNaturalDisplay( content );
content.style.height = startHeight + 'px';
content.style.overflow = 'hidden';


/*
* 강제 reflow
*/
content.offsetHeight;
content.offsetHeight;


var animation = content.animate(
var animation =
[
content.animate(
[
{
height:
startHeight + 'px'
},
{
height: '0px'
}
],
{
{
height: startHeight + 'px'
duration: DURATION,
},
easing: 'ease',
{
fill: 'forwards'
height: '0px'
}
}
],
);
{
duration: DURATION,
easing: 'ease',
fill: 'forwards'
}
);


animation.onfinish = function () {
animation.onfinish = function () {
target.classList.add( 'mw-collapsed' );
target.classList.add(
'mw-collapsed'
);


content.classList.remove( ANIMATING_CLASS );
content.classList.remove(
ANIMATING_CLASS
);


/*
content.style.display =
* 접힌 상태 유지
'none';
*/
content.style.display = 'none';


/*
* height와 overflow만 제거
*/
content.style.height = '';
content.style.height = '';
content.style.overflow = '';
content.style.overflow = '';
toggle.setAttribute(
'aria-expanded',
'false'
);


animation.cancel();
animation.cancel();
};
animation.oncancel = function () {
content.classList.remove( ANIMATING_CLASS );
};
};
}
}


/**
/**
* 실제 토글 처리
* 실제 토글 실행
*/
*/
function toggleCollapsible( toggle ) {
function toggleCollapsible( toggle ) {
var name = getToggleName( toggle );
var name =
getToggleName( toggle );


if ( !name ) {
if ( !name ) {
301번째 줄: 322번째 줄:
}
}


var target = getTarget( name );
var targets =
getTargets( name );


if ( !target ) {
if ( !targets.length ) {
return false;
return false;
}
}


var content = getContent( target );
var handled = false;
 
targets.forEach(
function ( target ) {
var content =
getContent(
target,
toggle
);
 
/*
* 버튼을 포함하고 있는 요소 자체라면
* 숨기지 않는다.
*/
if ( !content ) {
return;
}
 
if (
isCollapsed(
target,
content
)
) {
expand(
target,
content
);
} else {
collapse(
target,
content
);
}
 
handled = true;
}
);


if ( !content ) {
/*
return false;
* 버튼 aria 상태도 변경
}
*/
if ( handled ) {
var expanded =
toggle.getAttribute(
'aria-expanded'
) === 'true';


if ( isCollapsed( target, content ) ) {
toggle.setAttribute(
expand(
'aria-expanded',
target,
expanded
content,
? 'false'
toggle
: 'true'
);
} else {
collapse(
target,
content,
toggle
);
);
}
}


return true;
return handled;
}
}


/**
/**
* 마우스 클릭
* 클릭 처리
*
* MediaWiki 기본 customtoggle보다 먼저 실행하기 위해
* capture=true 사용
*/
*/
document.addEventListener(
document.addEventListener(
'click',
'click',
function ( event ) {
function ( event ) {
var toggle = event.target.closest(
var toggle =
'.mw-customtoggle'
event.target.closest(
);
'.mw-customtoggle'
);


if ( !toggle ) {
if ( !toggle ) {
347번째 줄: 403번째 줄:
}
}


if ( !getToggleName( toggle ) ) {
if (
!getToggleName( toggle )
) {
return;
return;
}
}


event.preventDefault();
event.preventDefault();
event.stopPropagation();
event.stopPropagation();
event.stopImmediatePropagation();
event.stopImmediatePropagation();


toggleCollapsible( toggle );
toggleCollapsible(
toggle
);
},
},
true
true
361번째 줄: 423번째 줄:


/**
/**
* Enter / Space 키 지원
* 키보드 Enter / Space
*/
*/
document.addEventListener(
document.addEventListener(
373번째 줄: 435번째 줄:
}
}


var toggle = event.target.closest(
var toggle =
'.mw-customtoggle'
event.target.closest(
);
'.mw-customtoggle'
);


if ( !toggle ) {
if ( !toggle ) {
381번째 줄: 444번째 줄:
}
}


if ( !getToggleName( toggle ) ) {
if (
!getToggleName( toggle )
) {
return;
return;
}
}


event.preventDefault();
event.preventDefault();
event.stopPropagation();
event.stopPropagation();
event.stopImmediatePropagation();
event.stopImmediatePropagation();


toggleCollapsible( toggle );
toggleCollapsible(
toggle
);
},
},
true
true

2026년 8월 20일 (목) 13:17 판

/**
 * MediaWiki custom toggle 애니메이션
 *
 * 예:
 *
 * mw-customtoggle-MALE
 *      ↕
 * mw-customcollapsible-MALE
 *
 * mw-customtoggle-FEMALE
 *      ↕
 * mw-customcollapsible-FEMALE
 *
 * 여러 개를 동시에 사용해도 각각 독립적으로 작동합니다.
 */

( function () {
	'use strict';

	var DURATION = 300;
	var ANIMATING_CLASS = 'mw-custom-collapse-animating';

	/**
	 * mw-customtoggle-XXX 에서 XXX 추출
	 */
	function getToggleName( toggle ) {
		var prefix = 'mw-customtoggle-';
		var classes = toggle.classList;
		var i;

		for ( i = 0; i < classes.length; i++ ) {
			if ( classes[ i ].indexOf( prefix ) === 0 ) {
				return classes[ i ].substring( prefix.length );
			}
		}

		return null;
	}

	/**
	 * CSS 선택자 escape
	 */
	function escapeSelector( value ) {
		if ( window.CSS && CSS.escape ) {
			return CSS.escape( value );
		}

		return value.replace(
			/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g,
			'\\\\$1'
		);
	}

	/**
	 * 해당 이름에 연결된 모든 대상 찾기
	 */
	function getTargets( name ) {
		if ( !name ) {
			return [];
		}

		var selector =
			'.mw-customcollapsible-' +
			escapeSelector( name );

		var targets =
			Array.prototype.slice.call(
				document.querySelectorAll( selector )
			);

		/*
		 * id 형식도 지원
		 */
		var idTarget = document.getElementById(
			'mw-customcollapsible-' + name
		);

		if (
			idTarget &&
			targets.indexOf( idTarget ) === -1
		) {
			targets.push( idTarget );
		}

		return targets;
	}

	/**
	 * 실제 애니메이션 대상 결정
	 *
	 * 중요:
	 * 토글 버튼 자체가 들어있는 요소 전체를
	 * display:none으로 만들지 않도록 한다.
	 */
	function getContent( target, toggle ) {
		var content = target.querySelector(
			'.mw-collapsible-content'
		);

		if ( content ) {
			return content;
		}

		/*
		 * target 자체가 toggle을 포함하지 않는다면
		 * target 자체를 접어도 안전하다.
		 */
		if ( !target.contains( toggle ) ) {
			return target;
		}

		/*
		 * toggle이 target 내부에 있다면
		 * target 전체를 숨기면 버튼까지 사라지므로
		 * 애니메이션하지 않는다.
		 */
		return null;
	}

	/**
	 * 태그의 자연스러운 display 값
	 */
	function getNaturalDisplay( element ) {
		switch ( element.tagName ) {
			case 'TABLE':
				return 'table';

			case 'TBODY':
				return 'table-row-group';

			case 'THEAD':
				return 'table-header-group';

			case 'TFOOT':
				return 'table-footer-group';

			case 'TR':
				return 'table-row';

			case 'TD':
			case 'TH':
				return 'table-cell';

			case 'COLGROUP':
				return 'table-column-group';

			case 'COL':
				return 'table-column';

			case 'CAPTION':
				return 'table-caption';

			case 'LI':
				return 'list-item';

			default:
				return 'block';
		}
	}

	/**
	 * 접힌 상태인지 확인
	 */
	function isCollapsed( target, content ) {
		if ( target.classList.contains( 'mw-collapsed' ) ) {
			return true;
		}

		return (
			window.getComputedStyle( content ).display ===
			'none'
		);
	}

	/**
	 * 펼치기
	 */
	function expand( target, content ) {
		if (
			content.classList.contains(
				ANIMATING_CLASS
			)
		) {
			return;
		}

		content.classList.add(
			ANIMATING_CLASS
		);

		target.classList.remove(
			'mw-collapsed'
		);

		content.style.display =
			getNaturalDisplay( content );

		content.style.height = 'auto';
		content.style.overflow = 'hidden';

		var targetHeight =
			content.scrollHeight;

		content.style.height = '0px';

		/*
		 * reflow
		 */
		content.offsetHeight;

		var animation =
			content.animate(
				[
					{
						height: '0px'
					},
					{
						height:
							targetHeight + 'px'
					}
				],
				{
					duration: DURATION,
					easing: 'ease',
					fill: 'forwards'
				}
			);

		animation.onfinish = function () {
			content.classList.remove(
				ANIMATING_CLASS
			);

			/*
			 * 애니메이션 후 원래 CSS 상태로 복구
			 */
			content.style.display = '';
			content.style.height = '';
			content.style.overflow = '';

			animation.cancel();
		};
	}

	/**
	 * 접기
	 */
	function collapse( target, content ) {
		if (
			content.classList.contains(
				ANIMATING_CLASS
			)
		) {
			return;
		}

		content.classList.add(
			ANIMATING_CLASS
		);

		var startHeight =
			content.getBoundingClientRect()
				.height;

		content.style.display =
			getNaturalDisplay( content );

		content.style.height =
			startHeight + 'px';

		content.style.overflow =
			'hidden';

		content.offsetHeight;

		var animation =
			content.animate(
				[
					{
						height:
							startHeight + 'px'
					},
					{
						height: '0px'
					}
				],
				{
					duration: DURATION,
					easing: 'ease',
					fill: 'forwards'
				}
			);

		animation.onfinish = function () {
			target.classList.add(
				'mw-collapsed'
			);

			content.classList.remove(
				ANIMATING_CLASS
			);

			content.style.display =
				'none';

			content.style.height = '';
			content.style.overflow = '';

			animation.cancel();
		};
	}

	/**
	 * 실제 토글 실행
	 */
	function toggleCollapsible( toggle ) {
		var name =
			getToggleName( toggle );

		if ( !name ) {
			return false;
		}

		var targets =
			getTargets( name );

		if ( !targets.length ) {
			return false;
		}

		var handled = false;

		targets.forEach(
			function ( target ) {
				var content =
					getContent(
						target,
						toggle
					);

				/*
				 * 버튼을 포함하고 있는 요소 자체라면
				 * 숨기지 않는다.
				 */
				if ( !content ) {
					return;
				}

				if (
					isCollapsed(
						target,
						content
					)
				) {
					expand(
						target,
						content
					);
				} else {
					collapse(
						target,
						content
					);
				}

				handled = true;
			}
		);

		/*
		 * 버튼 aria 상태도 변경
		 */
		if ( handled ) {
			var expanded =
				toggle.getAttribute(
					'aria-expanded'
				) === 'true';

			toggle.setAttribute(
				'aria-expanded',
				expanded
					? 'false'
					: 'true'
			);
		}

		return handled;
	}

	/**
	 * 클릭 처리
	 */
	document.addEventListener(
		'click',
		function ( event ) {
			var toggle =
				event.target.closest(
					'.mw-customtoggle'
				);

			if ( !toggle ) {
				return;
			}

			if (
				!getToggleName( toggle )
			) {
				return;
			}

			event.preventDefault();

			event.stopPropagation();

			event.stopImmediatePropagation();

			toggleCollapsible(
				toggle
			);
		},
		true
	);

	/**
	 * 키보드 Enter / Space
	 */
	document.addEventListener(
		'keydown',
		function ( event ) {
			if (
				event.key !== 'Enter' &&
				event.key !== ' '
			) {
				return;
			}

			var toggle =
				event.target.closest(
					'.mw-customtoggle'
				);

			if ( !toggle ) {
				return;
			}

			if (
				!getToggleName( toggle )
			) {
				return;
			}

			event.preventDefault();

			event.stopPropagation();

			event.stopImmediatePropagation();

			toggleCollapsible(
				toggle
			);
		},
		true
	);

}() );