Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
406 changes: 405 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"lint-staged": "^16.2.7",
"prettier": "3.7.4",
"typescript": "~5.9.3",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vite-plugin-svgr": "^4.5.0"
}
}
4 changes: 2 additions & 2 deletions src/assets/icons/refreshIcon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions src/assets/icons/replyIcon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/assets/icons/smallArrowIcon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/assets/icons/trashcanIcon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 0 additions & 5 deletions src/assets/icons/treshcanIcon.svg

This file was deleted.

457 changes: 0 additions & 457 deletions src/components/ScriptBox.tsx

This file was deleted.

124 changes: 124 additions & 0 deletions src/components/common/Popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from 'react';

import clsx from 'clsx';

type PopoverPosition = 'top' | 'bottom';
type PopoverAlign = 'start' | 'end';

interface PopoverProps {
trigger: ReactNode | ((props: { isOpen: boolean }) => ReactNode);
children: ReactNode;
position?: PopoverPosition;
align?: PopoverAlign;
className?: string;
ariaLabel?: string;
}

export function Popover({
trigger,
children,
position = 'top',
align = 'end',
className,
ariaLabel,
}: PopoverProps) {
const [isOpen, setIsOpen] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const popoverId = useId();

const handleToggle = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);

const handleClose = useCallback(() => {
setIsOpen(false);
// 팝오버 닫힐 때 트리거로 포커스 이동
triggerRef.current?.querySelector('button')?.focus();
}, []);
Comment on lines +33 to +54
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

팝오버가 닫힐 때 포커스를 복원하는 방식이 특정 구조(button 태그)에 의존하고 있어 유연성이 떨어집니다. triggerRef.current?.querySelector('button') 대신, 팝오버가 열릴 때의 document.activeElementuseRef에 저장해두었다가 닫힐 때 그 요소로 포커스를 되돌리는 것이 더 안정적이고 재사용성 높은 방법입니다.

  const [isOpen, setIsOpen] = useState(false);
  const popoverRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);
  const lastFocusedElement = useRef<HTMLElement | null>(null);
  const popoverId = useId();

  const handleToggle = useCallback(() => {
    setIsOpen((prev) => {
      if (!prev) {
        lastFocusedElement.current = document.activeElement as HTMLElement;
      }
      return !prev;
    });
  }, []);

  const handleClose = useCallback(() => {
    setIsOpen(false);
    // 팝오버 닫힐 때 이전에 포커스된 요소로 포커스 이동
    lastFocusedElement.current?.focus();
  }, []);


// Escape 키로 닫기
useEffect(() => {
if (!isOpen) return;

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
}
};

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleClose]);

// 외부 클릭 시 닫기
useEffect(() => {
if (!isOpen) return;

const handleClickOutside = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
handleClose();
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, handleClose]);

// 팝오버 열릴 때 첫 번째 포커스 가능한 요소로 포커스 이동
useEffect(() => {
if (!isOpen || !contentRef.current) return;

const focusableElements = contentRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);

if (focusableElements.length > 0) {
focusableElements[0].focus();
}
}, [isOpen]);
Comment on lines +84 to +95
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Popover가 열릴 때 자동으로 첫 번째 포커스 가능한 요소로 포커스를 이동시키는데, 이것이 모든 상황에 적합하지 않을 수 있습니다. 예를 들어 ScriptBoxEmoji 팝오버는 단순히 정보를 표시하는 용도이므로 자동 포커스가 불필요할 수 있습니다. autoFocus prop을 추가하여 이 동작을 선택적으로 제어할 수 있도록 하는 것을 권장합니다.

Copilot uses AI. Check for mistakes.

const positionClasses = clsx({
'bottom-full mb-2': position === 'top',
'top-full mt-2': position === 'bottom',
});

const alignClasses = clsx({
'left-0': align === 'start',
'right-0': align === 'end',
});

return (
<div ref={popoverRef} className="relative">
<div
ref={triggerRef}
onClick={handleToggle}
aria-haspopup="dialog"
aria-expanded={isOpen}
aria-controls={isOpen ? popoverId : undefined}
>
{typeof trigger === 'function' ? trigger({ isOpen }) : trigger}
</div>
Comment on lines +109 to +117
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

접근성 관점에서 div와 같은 정적(non-interactive) 요소에 onClick 핸들러를 직접 사용하는 것은 좋지 않습니다. 스크린 리더 사용자와 키보드 사용자가 해당 요소를 인지하고 상호작용하기 어렵기 때문입니다. 또한, aria-* 속성들은 실제 상호작용하는 요소(이 경우 trigger 내부의 button)에 직접 적용되어야 더 의미론적으로 올바릅니다.

이 문제를 해결하기 위해 div 래퍼를 제거하고, React.cloneElement를 사용하여 trigger로 전달된 요소에 onClickaria-* 속성들을 직접 주입하는 방식으로 리팩토링하는 것을 권장합니다. 이렇게 하면 Popover 컴포넌트의 유연성을 유지하면서도 시맨틱하고 접근성 높은 코드를 작성할 수 있습니다.


{isOpen && (
<div
ref={contentRef}
id={popoverId}
role="dialog"
aria-label={ariaLabel}
aria-modal="false"
Comment on lines +123 to +125
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Popover 컴포넌트가 role="dialog"aria-modal="false"를 사용하고 있습니다. 하지만 popover는 일반적으로 dialog가 아니라 보조 컨텐츠를 표시하는 용도입니다. ARIA 1.2부터 지원되는 role="menu" (메뉴형 팝오버인 경우) 또는 일반 컨텐츠 컨테이너로 처리하는 것이 더 적절할 수 있습니다. 또는 트리거 버튼에 aria-haspopup="true" 대신 구체적인 값 (예: aria-haspopup="menu")을 사용하는 것을 권장합니다.

Copilot uses AI. Check for mistakes.
className={clsx(
'absolute z-50',
positionClasses,
alignClasses,
'rounded-lg bg-white shadow-[0_0.25rem_1.25rem_rgba(0,0,0,0.05)]',
className,
)}
>
{children}
</div>
)}
</div>
);
}
1 change: 1 addition & 0 deletions src/components/common/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { LoginButton } from './LoginButton';
export { Logo } from './Logo';
export { Popover } from './Popover';
Loading