UNPKG

@restnfeel/agentc-starter-kit

Version:

한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템

331 lines (323 loc) 14.3 kB
"use client"; import { jsxs, Fragment, jsx } from 'react/jsx-runtime'; import { useState, useRef, useEffect, useCallback } from 'react'; import { useI18n } from './i18n-context.js'; function FloatingChatButton({ onClick, isOpen = false, unreadCount = 0, position = "bottom-right", size = "md", color = "#3B82F6", className = "", disabled = false, ariaLabel, theme = "auto", hideOnMobile = false, customIcon, enableKeyboardShortcut = true, shortcutKey = "KeyC", }) { const { t } = useI18n(); const [isHovered, setIsHovered] = useState(false); const [isFocused, setIsFocused] = useState(false); const [isPressed, setIsPressed] = useState(false); const [isMobile, setIsMobile] = useState(false); const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); const [prefersHighContrast, setPrefersHighContrast] = useState(false); const [lastInteraction, setLastInteraction] = useState("mouse"); const buttonRef = useRef(null); const liveRegionRef = useRef(null); // 접근성 설정 감지 useEffect(() => { const checkAccessibilityPreferences = () => { // 애니메이션 감소 설정 감지 if (window.matchMedia) { const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); setPrefersReducedMotion(reducedMotionQuery.matches); const handleReducedMotionChange = (e) => { setPrefersReducedMotion(e.matches); }; // 브라우저 호환성을 위해 addListener 사용 if (reducedMotionQuery.addListener) { reducedMotionQuery.addListener(handleReducedMotionChange); } else { reducedMotionQuery.addEventListener("change", handleReducedMotionChange); } // 고대비 모드 감지 const highContrastQuery = window.matchMedia("(prefers-contrast: high)"); setPrefersHighContrast(highContrastQuery.matches); const handleHighContrastChange = (e) => { setPrefersHighContrast(e.matches); }; if (highContrastQuery.addListener) { highContrastQuery.addListener(handleHighContrastChange); } else { highContrastQuery.addEventListener("change", handleHighContrastChange); } return () => { if (reducedMotionQuery.removeListener) { reducedMotionQuery.removeListener(handleReducedMotionChange); } else { reducedMotionQuery.removeEventListener("change", handleReducedMotionChange); } if (highContrastQuery.removeListener) { highContrastQuery.removeListener(handleHighContrastChange); } else { highContrastQuery.removeEventListener("change", handleHighContrastChange); } }; } }; checkAccessibilityPreferences(); }, []); // 모바일 디바이스 감지 useEffect(() => { const checkMobile = () => { const userAgent = navigator.userAgent.toLowerCase(); const isMobileDevice = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/.test(userAgent); const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0; setIsMobile(isMobileDevice || isTouchDevice); }; checkMobile(); window.addEventListener("resize", checkMobile); return () => window.removeEventListener("resize", checkMobile); }, []); // 키보드 단축키 지원 useEffect(() => { if (!enableKeyboardShortcut) return; const handleKeyDown = (event) => { // Alt + C (또는 사용자 정의 키) if (event.altKey && event.code === shortcutKey && !disabled) { event.preventDefault(); setLastInteraction("keyboard"); onClick(); // 포커스를 버튼으로 이동 if (buttonRef.current) { buttonRef.current.focus(); } // 스크린 리더에 알림 announceToScreenReader(isOpen ? "Chat closed via keyboard shortcut" : "Chat opened via keyboard shortcut"); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [enableKeyboardShortcut, shortcutKey, disabled, onClick, isOpen, t]); // 스크린 리더 알림 함수 const announceToScreenReader = useCallback((message) => { if (liveRegionRef.current) { liveRegionRef.current.textContent = message; // 짧은 지연 후 메시지 클리어 setTimeout(() => { if (liveRegionRef.current) { liveRegionRef.current.textContent = ""; } }, 1000); } }, []); // 클릭 핸들러 개선 const handleClick = useCallback((event) => { if (disabled) return; event.preventDefault(); setLastInteraction("mouse"); // 햅틱 피드백 (지원되는 경우) if ("vibrate" in navigator && isMobile) { navigator.vibrate(50); } onClick(); // 상태 변화를 스크린 리더에 알림 announceToScreenReader(isOpen ? "Closing chat" : "Opening chat"); }, [disabled, onClick, isOpen, isMobile, announceToScreenReader, t]); // 키보드 핸들러 개선 const handleKeyDown = useCallback((event) => { if (disabled) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setLastInteraction("keyboard"); setIsPressed(true); } }, [disabled]); const handleKeyUp = useCallback((event) => { if (disabled) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setIsPressed(false); onClick(); // 상태 변화를 스크린 리더에 알림 announceToScreenReader(isOpen ? "Closing chat" : "Opening chat"); } }, [disabled, onClick, isOpen, announceToScreenReader, t]); // 터치 핸들러 const handleTouchStart = useCallback(() => { if (disabled) return; setLastInteraction("touch"); setIsPressed(true); }, [disabled]); const handleTouchEnd = useCallback(() => { if (disabled) return; setIsPressed(false); }, [disabled]); // 포커스 핸들러 const handleFocus = useCallback(() => { setIsFocused(true); }, []); const handleBlur = useCallback(() => { setIsFocused(false); setIsPressed(false); }, []); // 마우스 핸들러 const handleMouseEnter = useCallback(() => { if (!isMobile) { setIsHovered(true); } }, [isMobile]); const handleMouseLeave = useCallback(() => { setIsHovered(false); setIsPressed(false); }, []); const handleMouseDown = useCallback(() => { if (disabled) return; setLastInteraction("mouse"); setIsPressed(true); }, [disabled]); const handleMouseUp = useCallback(() => { setIsPressed(false); }, []); // 크기 계산 const getSizeClasses = () => { const baseSize = isMobile ? "lg" : size; switch (baseSize) { case "sm": return "w-12 h-12 text-sm"; case "lg": return "w-16 h-16 text-lg"; default: return "w-14 h-14 text-base"; } }; // 위치 계산 const getPositionClasses = () => { const offset = isMobile ? "4" : "6"; switch (position) { case "bottom-left": return `bottom-${offset} left-${offset}`; case "top-right": return `top-${offset} right-${offset}`; case "top-left": return `top-${offset} left-${offset}`; default: return `bottom-${offset} right-${offset}`; } }; // 접근성 라벨 생성 const getAriaLabel = () => { if (ariaLabel) return ariaLabel; const baseLabel = isOpen ? t("close") || "Close chat" : t("chatbot") || "Open chat"; const unreadText = unreadCount > 0 ? ` (${unreadCount} ${t("typing") || "unread messages"})` : ""; const shortcutText = enableKeyboardShortcut ? " Press Alt+C for keyboard shortcut" : ""; return `${baseLabel}${unreadText}${shortcutText}`; }; // 스타일 계산 const buttonStyles = { "--chat-button-color": color, "--chat-button-hover": `${color}dd`, "--chat-button-active": `${color}bb`, backgroundColor: isPressed ? "var(--chat-button-active)" : isHovered ? "var(--chat-button-hover)" : "var(--chat-button-color)", transform: prefersReducedMotion ? "none" : isPressed ? "scale(0.95)" : isHovered ? "scale(1.05)" : "scale(1)", transition: prefersReducedMotion ? "none" : "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)", willChange: prefersReducedMotion ? "auto" : "transform, background-color", }; if (hideOnMobile && isMobile) { return null; } return (jsxs(Fragment, { children: [jsx("div", { ref: liveRegionRef, "aria-live": "polite", "aria-atomic": "true", className: "sr-only", role: "status" }), jsxs("button", { ref: buttonRef, onClick: handleClick, onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, onTouchStart: handleTouchStart, onTouchEnd: handleTouchEnd, onFocus: handleFocus, onBlur: handleBlur, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onMouseDown: handleMouseDown, onMouseUp: handleMouseUp, disabled: disabled, "aria-label": getAriaLabel(), "aria-expanded": isOpen, "aria-describedby": unreadCount > 0 ? "chat-unread-count" : undefined, "aria-keyshortcuts": enableKeyboardShortcut ? "Alt+C" : undefined, role: "button", tabIndex: 0, style: buttonStyles, className: ` fixed z-50 rounded-full shadow-lg flex items-center justify-center ${getSizeClasses()} ${getPositionClasses()} ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"} ${isFocused && lastInteraction === "keyboard" ? "ring-4 ring-blue-300 ring-opacity-75" : ""} ${prefersHighContrast ? "border-2 border-white" : ""} hover:shadow-xl active:shadow-md focus:outline-none select-none touch-manipulation ${className} `, children: [customIcon || (jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", xmlns: "http://www.w3.org/2000/svg", className: "text-white", "aria-hidden": "true", children: [jsx("path", { d: "M20 2H4C2.9 2 2 2.9 2 4V22L6 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2Z", fill: "currentColor" }), jsx("circle", { cx: "8", cy: "10", r: "1.5", fill: "white", opacity: "0.8" }), jsx("circle", { cx: "12", cy: "10", r: "1.5", fill: "white", opacity: "0.8" }), jsx("circle", { cx: "16", cy: "10", r: "1.5", fill: "white", opacity: "0.8" })] })), unreadCount > 0 && (jsx("span", { id: "chat-unread-count", className: ` absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full min-w-[20px] h-5 flex items-center justify-center ${prefersHighContrast ? "border border-white" : ""} ${prefersReducedMotion ? "" : "animate-pulse"} `, "aria-label": `${unreadCount} ${t("typing") || "unread messages"}`, children: unreadCount > 99 ? "99+" : unreadCount })), !isMobile && isHovered && !isFocused && (jsxs("div", { className: ` absolute ${position.includes("bottom") ? "bottom-full mb-2" : "top-full mt-2"} ${position.includes("right") ? "right-0" : "left-0"} bg-gray-900 text-white text-sm px-3 py-1 rounded whitespace-nowrap pointer-events-none ${prefersReducedMotion ? "" : "animate-fade-in"} ${prefersHighContrast ? "border border-white" : ""} `, role: "tooltip", children: [isOpen ? t("close") || "Close chat" : t("chatbot") || "Open chat", enableKeyboardShortcut && (jsx("span", { className: "block text-xs opacity-75 mt-1", children: "Alt+C" }))] }))] }), jsx("style", { jsx: true, children: ` @media (prefers-reduced-motion: reduce) { button { transition: none !important; animation: none !important; transform: none !important; } } @media (prefers-contrast: high) { button { border: 2px solid white !important; box-shadow: 0 0 0 2px black !important; } } @media (max-width: 768px) { button { min-height: 44px !important; min-width: 44px !important; } } .animate-fade-in { animation: fadeIn 0.2s ease-out; } @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } @media (prefers-reduced-motion: reduce) { .animate-fade-in { animation: none; } } ` })] })); } export { FloatingChatButton, FloatingChatButton as default }; //# sourceMappingURL=floating-chat-button.js.map