@restnfeel/agentc-starter-kit
Version:
한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템
331 lines (323 loc) • 14.3 kB
JavaScript
"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