@cp949/mui
Version:
CP949 MUI React component library
216 lines (183 loc) • 7.36 kB
text/typescript
'use client';
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useLatest } from '../../hooks/useLatest.js';
import { useMutationObserver } from '../../hooks/useMutationObserver.js';
import { useTimeout } from '../../hooks/useTimeout.js';
import { isNotNil } from '../../util/internal-util.js';
/**
* 문자열 값을 정수로 변환하는 유틸리티 함수입니다.
* @param value 변환할 값
* @returns 정수 값으로 변환된 숫자
*/
function toInt(value?: string) {
return value ? parseInt(value, 10) : 0;
}
/**
* 부모 요소가 자식 요소를 포함하는지 여부를 확인하는 함수입니다.
* @param parentElement 부모 요소
* @param childElement 자식 요소
* @returns 부모가 자식을 포함하는 경우 true, 그렇지 않으면 false
*/
function isParent(
parentElement: HTMLElement | EventTarget | null,
childElement: HTMLElement | null,
) {
if (!childElement || !parentElement) {
return false;
}
let parent = childElement.parentNode;
while (isNotNil(parent)) {
if (parent === parentElement) {
return true;
}
parent = parent.parentNode;
}
return false;
}
interface UseFloatingIndicatorInput {
/** 표시할 인디케이터의 대상 요소 */
target: HTMLElement | null | undefined;
/** 인디케이터가 위치할 부모 요소 */
parent: HTMLElement | null | undefined;
/** 인디케이터 DOM 참조 */
indicatorRef: RefObject<HTMLDivElement>;
/** 인디케이터 크기 계산 시 대상 요소의 보더 두께를 제외할지 여부 */
excludeTargetBorderWidth?: boolean;
/** 전환이 끝난 후에 인디케이터를 표시할지 여부 */
displayAfterTransitionEnd?: boolean;
}
/**
* `target` 요소의 위치와 크기를 기반으로 인디케이터를 위치시킵니다.
* 인디케이터는 `parent` 요소를 기준으로 절대 위치가 지정되며, 보더 두께를 고려하여 계산됩니다.
* @param target 대상 요소
* @param parent 부모 요소
* @param indicatorRef 인디케이터 DOM 참조
* @param excludeTargetBorderWidth 대상 요소의 보더 두께를 제외할지 여부
* @param displayAfterTransitionEnd 전환 종료 후에 인디케이터 표시 여부
* @returns 초기화 여부와 숨김 상태 반환
*/
export function useFloatingIndicator({
target,
parent,
indicatorRef,
excludeTargetBorderWidth = false,
displayAfterTransitionEnd = false,
}: UseFloatingIndicatorInput) {
const transitionTimeout = useRef(-1);
const [initialized, setInitialized] = useState(false);
const [hidden, setHidden] = useState(displayAfterTransitionEnd);
/**
* 인디케이터의 위치를 업데이트하는 함수입니다.
* target과 parent 요소의 크기 및 위치를 계산하여 인디케이터에 적용합니다.
*/
const updatePosition = useLatest(() => {
if (!target || !parent || !indicatorRef.current) {
return;
}
// getBoundingClientRect()를 사용하여 대상과 부모 요소의 크기 및 위치를 가져옵니다
// getBoundingClientRect()는 보더의 두께를 포함합니다
const targetRect = target.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
const targetComputedStyle = window.getComputedStyle(target);
const parentComputedStyle = window.getComputedStyle(parent);
const parentBorderWidth = {
top: toInt(parentComputedStyle.borderTopWidth),
left: toInt(parentComputedStyle.borderLeftWidth),
};
const targetBorderWidth = {
top: toInt(targetComputedStyle.borderTopWidth),
bottom: toInt(targetComputedStyle.borderBottomWidth),
left: toInt(targetComputedStyle.borderLeftWidth),
right: toInt(targetComputedStyle.borderRightWidth),
};
// 인디케이터의 위치 및 크기 계산
const indicatorRect = {
top: targetRect.top - parentRect.top - parentBorderWidth.top,
left: targetRect.left - parentRect.left - parentBorderWidth.left,
width: targetRect.width,
height: targetRect.height,
};
// 보더 두께 제외 설정이 활성화되면 보더 두께를 제외한 크기로 설정
if (excludeTargetBorderWidth) {
indicatorRect.top += targetBorderWidth.top;
indicatorRect.left += targetBorderWidth.left;
indicatorRect.height -= targetBorderWidth.top + targetBorderWidth.bottom;
indicatorRect.width -= targetBorderWidth.left + targetBorderWidth.right;
}
// 인디케이터 스타일 업데이트
indicatorRef.current.style.transform = `translateX(${indicatorRect.left}px) translateY(${indicatorRect.top}px)`;
indicatorRef.current.style.width = `${indicatorRect.width}px`;
indicatorRef.current.style.height = `${indicatorRect.height}px`;
});
/**
* 애니메이션 없이 인디케이터의 위치를 업데이트하는 함수입니다.
* 애니메이션을 제거한 후 즉시 위치를 업데이트하고, 잠시 후 다시 전환 지속 시간을 원상 복구합니다.
*/
const updatePositionWithoutAnimation = useLatest(() => {
window.clearTimeout(transitionTimeout.current);
if (indicatorRef.current) {
indicatorRef.current.style.transitionDuration = '0ms';
}
updatePosition.current();
transitionTimeout.current = window.setTimeout(() => {
if (indicatorRef.current) {
indicatorRef.current.style.transitionDuration = '';
}
}, 30);
});
const targetResizeObserver = useRef<ResizeObserver | null>(null);
const parentResizeObserver = useRef<ResizeObserver | null>(null);
useEffect(() => {
updatePosition.current();
// target과 parent 요소에 리사이즈 이벤트 리스너 설정
if (target) {
targetResizeObserver.current = new ResizeObserver(updatePositionWithoutAnimation.current);
targetResizeObserver.current.observe(target);
if (parent) {
parentResizeObserver.current = new ResizeObserver(updatePositionWithoutAnimation.current);
parentResizeObserver.current.observe(parent);
}
return () => {
targetResizeObserver.current?.disconnect();
parentResizeObserver.current?.disconnect();
};
}
return undefined;
}, [parent, target, updatePosition, updatePositionWithoutAnimation]);
useEffect(() => {
if (parent) {
const handleTransitionEnd = (event: TransitionEvent) => {
if (isParent(event.target, parent)) {
updatePositionWithoutAnimation.current();
setHidden(false);
}
};
parent.addEventListener('transitionend', handleTransitionEnd);
return () => {
parent.removeEventListener('transitionend', handleTransitionEnd);
};
}
return undefined;
}, [parent, updatePositionWithoutAnimation]);
// 초기화 타이머 설정
useTimeout(
() => {
setInitialized(true);
},
20,
{ autoInvoke: true },
);
// DOM 변화 감지 및 처리
useMutationObserver(
(mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'dir') {
updatePositionWithoutAnimation.current();
}
});
},
{ attributes: true, attributeFilter: ['dir'] },
() => document.documentElement,
);
return { initialized, hidden };
}