diff --git a/frontend/src/components/common/AvatarCircle/AvatarCircle.tsx b/frontend/src/components/common/AvatarCircle/AvatarCircle.tsx index 653341cb..ffeedd5b 100644 --- a/frontend/src/components/common/AvatarCircle/AvatarCircle.tsx +++ b/frontend/src/components/common/AvatarCircle/AvatarCircle.tsx @@ -3,19 +3,18 @@ import useImageError from "@hooks/useImageError"; import * as S from "./AvatarCircle.styled"; import type { AvatarCircleSize } from "./AvatarCircle.type"; -interface AvatarCircleProps { +interface AvatarCircleProps extends React.ImgHTMLAttributes { $size?: AvatarCircleSize; profileImageUrl?: string; - imageAlt?: string; } -const AvatarCircle = ({ $size = "small", profileImageUrl, imageAlt }: AvatarCircleProps) => { +const AvatarCircle = ({ $size = "small", profileImageUrl, ...props }: AvatarCircleProps) => { const { imageError, handleImageError } = useImageError({ imageUrl: profileImageUrl }); return ( {!imageError ? ( - {imageAlt} + ) : ( { +interface ChipOwnProps { + as?: Element; label: string; isSelected?: boolean; index?: number; } -const Chip = ({ label, isSelected = false, index, children, ...props }: ChipProps) => { +type ChipProps = ChipOwnProps & + Omit, keyof ChipOwnProps>; + +const Chip = ({ + as, + label, + isSelected = false, + index, + children, + ...props +}: ChipProps) => { + const Component = as ?? DEFAULT_ELEMENT; + return ( ); }; - export default Chip; diff --git a/frontend/src/components/common/Chip/constants.ts b/frontend/src/components/common/Chip/constants.ts new file mode 100644 index 00000000..ca829f13 --- /dev/null +++ b/frontend/src/components/common/Chip/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_ELEMENT = "li" as const; diff --git a/frontend/src/components/common/Drawer/Drawer.tsx b/frontend/src/components/common/Drawer/Drawer.tsx index 45614af4..4a2a6f04 100644 --- a/frontend/src/components/common/Drawer/Drawer.tsx +++ b/frontend/src/components/common/Drawer/Drawer.tsx @@ -1,15 +1,18 @@ -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; +import ReactDOM from "react-dom"; import DrawerProvider, { useDrawerContext } from "@contexts/DrawerProvider"; import useModalControl from "@hooks/useModalControl"; +import usePressESC from "@hooks/usePressESC"; +import VisuallyHidden from "../VisuallyHidden/VisuallyHidden"; import * as S from "./Drawer.styled"; const Drawer = ({ children }: React.PropsWithChildren) => { const [isOpen, setIsOpen] = useState(false); - - const toggleDrawer = () => setIsOpen((prev) => !prev); + const toggleDrawer = useCallback(() => setIsOpen((prev) => !prev), []); + usePressESC(isOpen, toggleDrawer); useModalControl(isOpen, toggleDrawer); @@ -31,12 +34,19 @@ const Drawer = ({ children }: React.PropsWithChildren) => { return ( + + {isOpen ? "사용자 메뉴 모달이 열렸습니다." : "사용자 메뉴 모달이 닫혔습니다."} + {otherContent} - - {headerContent} - {drawerContent} - + {isOpen && + ReactDOM.createPortal( + + {headerContent} + {drawerContent} + , + document.body, + )} ); }; @@ -49,10 +59,21 @@ const Content = ({ children }: React.PropsWithChildren) => { return {children}; }; -const Trigger = ({ children }: React.PropsWithChildren) => { +interface TriggerProps extends React.ButtonHTMLAttributes { + children: React.ReactNode; + onClick?: (event: React.MouseEvent) => void; +} + +const Trigger = ({ children, onClick }: TriggerProps) => { const { toggleDrawer } = useDrawerContext(); + + const handleClick = (event: React.MouseEvent) => { + toggleDrawer(); + onClick?.(event); + }; + return ( - + {children} ); diff --git a/frontend/src/components/common/FallbackImage/FallbackImage.styled.ts b/frontend/src/components/common/FallbackImage/FallbackImage.styled.ts index e5b0706e..fe7bd415 100644 --- a/frontend/src/components/common/FallbackImage/FallbackImage.styled.ts +++ b/frontend/src/components/common/FallbackImage/FallbackImage.styled.ts @@ -12,5 +12,5 @@ export const Fallback = styled.div` background-color: #eee; ${(props) => props.theme.typography.mobile.detailBold}; - color: #9e9e9e; + color: ${({ theme }) => theme.colors.text.secondary}; `; diff --git a/frontend/src/components/common/FloatingButton/FloatingButton.tsx b/frontend/src/components/common/FloatingButton/FloatingButton.tsx index 62e47e48..c036b823 100644 --- a/frontend/src/components/common/FloatingButton/FloatingButton.tsx +++ b/frontend/src/components/common/FloatingButton/FloatingButton.tsx @@ -3,13 +3,16 @@ import { useNavigate } from "react-router-dom"; import useModalControl from "@hooks/useModalControl"; -import { ROUTE_PATHS_MAP } from "@constants/route"; +import { removeEmoji } from "@utils/removeEmojis"; import { PRIMITIVE_COLORS } from "@styles/tokens"; +import FocusTrap from "../FocusTrap"; import IconButton from "../IconButton/IconButton"; import Text from "../Text/Text"; +import VisuallyHidden from "../VisuallyHidden/VisuallyHidden"; import * as S from "./FloatingButton.styled"; +import SUB_BUTTONS from "./constants"; const FloatingButton = () => { const [isOpen, setIsOpen] = useState(false); @@ -19,34 +22,47 @@ const FloatingButton = () => { setIsOpen((prev) => !prev); }; - const handleClickTravelogueRegister = () => { - navigate(ROUTE_PATHS_MAP.travelogueRegister); - }; - - const handleClickTravelPlanRegister = () => { - navigate(ROUTE_PATHS_MAP.travelPlanRegister); + const handleClickSubButton = (route: string) => { + navigate(route); }; useModalControl(isOpen, handleToggleButton); return ( - {isOpen && } - - - - ✈️ 여행 계획 작성 - - - - - 📝 여행기 작성 - - - + + {isOpen + ? "여행기 및 여행 계획 작성 플로팅 버튼이 열렸습니다. 닫으려면 esc버튼을 눌러주세요." + : "여행기 및 여행 계획 작성 플로팅 버튼이 닫혔습니다."} + + {isOpen && ( + <> + + + + {SUB_BUTTONS.map(({ text, route }) => ( + handleClickSubButton(route)} + aria-label={removeEmoji(text)} + > + + {text} + + + ))} + + + + )} - + ); diff --git a/frontend/src/components/common/FloatingButton/constants.ts b/frontend/src/components/common/FloatingButton/constants.ts new file mode 100644 index 00000000..403d7f9a --- /dev/null +++ b/frontend/src/components/common/FloatingButton/constants.ts @@ -0,0 +1,14 @@ +import { ROUTE_PATHS_MAP } from "@constants/route"; + +const SUB_BUTTONS = [ + { + text: "✈️ 여행 계획 작성", + route: ROUTE_PATHS_MAP.travelPlanRegister, + }, + { + text: "📝 여행기 작성", + route: ROUTE_PATHS_MAP.travelogueRegister, + }, +]; + +export default SUB_BUTTONS; diff --git a/frontend/src/components/common/FocusTrap.tsx b/frontend/src/components/common/FocusTrap.tsx new file mode 100644 index 00000000..ce638208 --- /dev/null +++ b/frontend/src/components/common/FocusTrap.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useRef } from "react"; + +const getFocusableElements = (element: HTMLElement | null): HTMLElement[] => { + if (!element) return []; + + const focusableSelectors = [ + "a[href]", + "button", + "input", + "textarea", + "select", + '[tabindex]:not([tabindex="-1"])', + "li", + ].join(","); + + return Array.from(element.querySelectorAll(focusableSelectors)).filter( + (el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden"), + ); +}; + +interface Props extends React.ComponentPropsWithoutRef<"div"> { + children: React.ReactElement; + onEscapeFocusTrap?: () => void; +} + +const FocusTrap = ({ children, onEscapeFocusTrap, ...props }: Props) => { + const focusTrapRef = useRef(null); + const child = React.Children.only(children); + const focusableElements = useRef<(HTMLElement | null)[]>([]); + const currentFocusIndex = useRef(-1); + + useEffect(() => { + if (focusTrapRef.current) { + focusableElements.current = getFocusableElements(focusTrapRef.current); + } + + return () => { + focusableElements.current = []; + }; + }, []); + + useEffect(() => { + const focusNextElement = () => { + currentFocusIndex.current = + (currentFocusIndex.current + 1) % focusableElements.current.length; + focusableElements.current[currentFocusIndex.current]?.focus(); + }; + + const focusPrevElement = () => { + currentFocusIndex.current = + (currentFocusIndex.current - 1 + focusableElements.current.length) % + focusableElements.current.length; + focusableElements.current[currentFocusIndex.current]?.focus(); + }; + + const handleTabKeyDown = (event: KeyboardEvent) => { + const isTabKeyDown = !event.shiftKey && event.key === "Tab"; + if (!isTabKeyDown) return; + + event.preventDefault(); + focusNextElement(); + }; + + const handleShiftTabKeyDown = (event: KeyboardEvent) => { + const isShiftTabKeyDown = event.shiftKey && event.key === "Tab"; + if (!isShiftTabKeyDown) return; + + event.preventDefault(); + focusPrevElement(); + }; + + const handleEscapeKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && onEscapeFocusTrap) { + onEscapeFocusTrap(); + } + }; + + const handleKeyPress = (event: KeyboardEvent) => { + handleTabKeyDown(event); + handleShiftTabKeyDown(event); + onEscapeFocusTrap && handleEscapeKeyDown(event); + }; + + document.addEventListener("keydown", handleKeyPress); + + return () => document.removeEventListener("keydown", handleKeyPress); + }, [onEscapeFocusTrap]); + + const Component = React.cloneElement(child, { + ...{ ...props, ...child?.props }, + ref: focusTrapRef, + }); + + return <>{Component}; +}; + +export default FocusTrap; diff --git a/frontend/src/components/common/Header/DefaultHeader/DefaultHeader.tsx b/frontend/src/components/common/Header/DefaultHeader/DefaultHeader.tsx index a1f389ad..3797d96c 100644 --- a/frontend/src/components/common/Header/DefaultHeader/DefaultHeader.tsx +++ b/frontend/src/components/common/Header/DefaultHeader/DefaultHeader.tsx @@ -15,6 +15,7 @@ const DefaultHeader = () => { iconType="home-icon" size="20" onClick={() => navigation(ROUTE_PATHS_MAP.root)} + aria-label="홈 이동" /> } isHamburgerUsed diff --git a/frontend/src/components/common/Header/Header.styled.ts b/frontend/src/components/common/Header/Header.styled.ts index 5868ff73..78832d78 100644 --- a/frontend/src/components/common/Header/Header.styled.ts +++ b/frontend/src/components/common/Header/Header.styled.ts @@ -23,13 +23,7 @@ export const DrawHeaderContainer = styled.div` display: flex; `; -export const MenuItem = styled.li` - ${(props) => props.theme.typography.mobile.bodyBold}; - padding: ${({ theme }) => theme.spacing.s}; - cursor: pointer; -`; - -export const MenuList = styled.ul` +export const MenuList = styled.div` display: flex; flex-direction: column; justify-content: space-between; diff --git a/frontend/src/components/common/Header/Header.tsx b/frontend/src/components/common/Header/Header.tsx index 42c979c2..b50a5cb0 100644 --- a/frontend/src/components/common/Header/Header.tsx +++ b/frontend/src/components/common/Header/Header.tsx @@ -8,10 +8,11 @@ import { ROUTE_PATHS_MAP } from "@constants/route"; import theme from "@styles/theme"; import { PRIMITIVE_COLORS } from "@styles/tokens"; -import { DoubleRightArrow } from "@assets/svg"; - import Drawer from "../Drawer/Drawer"; +import FocusTrap from "../FocusTrap"; +import Icon from "../Icon/Icon"; import IconButton from "../IconButton/IconButton"; +import Text from "../Text/Text"; import * as S from "./Header.styled"; interface HeaderProps { @@ -52,6 +53,7 @@ const Header = ({ navigate(ROUTE_PATHS_MAP.root) : () => goBack()} @@ -62,38 +64,38 @@ const Header = ({ {rightContent} {isHamburgerUsed && ( - + )} - - - - - + + + - - - 마이페이지 - - + + + + 마이페이지 + {user?.accessToken ? ( - 로그아웃 + + 로그아웃 + ) : ( - { navigate(ROUTE_PATHS_MAP.login); }} > - 로그인 - + 로그인 + )} - - + + ); diff --git a/frontend/src/components/common/Header/HomePageHeader/HomePageHeader.tsx b/frontend/src/components/common/Header/HomePageHeader/HomePageHeader.tsx index a9aa846b..a71c699d 100644 --- a/frontend/src/components/common/Header/HomePageHeader/HomePageHeader.tsx +++ b/frontend/src/components/common/Header/HomePageHeader/HomePageHeader.tsx @@ -17,6 +17,7 @@ const HomePageHeader = () => { iconType="search-icon" size="18" onClick={() => navigation(ROUTE_PATHS_MAP.searchMain)} + aria-label="여행기 검색" /> } isHamburgerUsed diff --git a/frontend/src/components/common/Header/SearchHeader/SearchHeader.tsx b/frontend/src/components/common/Header/SearchHeader/SearchHeader.tsx index b133dd84..a44d53d8 100644 --- a/frontend/src/components/common/Header/SearchHeader/SearchHeader.tsx +++ b/frontend/src/components/common/Header/SearchHeader/SearchHeader.tsx @@ -87,13 +87,14 @@ const SearchHeader = () => { > - + navigate(ROUTE_PATHS_MAP.root)} + aria-label="홈 이동" /> } diff --git a/frontend/src/components/common/Header/SearchResultPageHeaderHeader/SearchResultPageHeader.tsx b/frontend/src/components/common/Header/SearchResultPageHeaderHeader/SearchResultPageHeader.tsx index d9c5f28c..e9a99dd2 100644 --- a/frontend/src/components/common/Header/SearchResultPageHeaderHeader/SearchResultPageHeader.tsx +++ b/frontend/src/components/common/Header/SearchResultPageHeaderHeader/SearchResultPageHeader.tsx @@ -14,6 +14,7 @@ const SearchResultPageHeader = () => { rightContent={ navigation(ROUTE_PATHS_MAP.searchMain)} /> diff --git a/frontend/src/components/common/Modal/Modal.tsx b/frontend/src/components/common/Modal/Modal.tsx index d29d4c8a..9e16ae7e 100644 --- a/frontend/src/components/common/Modal/Modal.tsx +++ b/frontend/src/components/common/Modal/Modal.tsx @@ -8,6 +8,7 @@ import ModalHeader from "@components/common/Modal/ModalHeader/ModalHeader"; import useBottomSheet from "@hooks/useBottomSheet"; import useModalControl from "@hooks/useModalControl"; +import FocusTrap from "../FocusTrap"; import * as S from "./Modal.style"; import { GapSize } from "./Modal.type"; @@ -31,22 +32,24 @@ const Modal = ({ return ReactDOM.createPortal( - {position === "center" ? ( - - {children} - - ) : ( - - {children} - - )} + + {position === "center" ? ( + + {children} + + ) : ( + + {children} + + )} + , - document.querySelector("#root") as HTMLElement, + document.body, ); }; diff --git a/frontend/src/components/common/VisuallyHidden/VisuallyHidden.styled.ts b/frontend/src/components/common/VisuallyHidden/VisuallyHidden.styled.ts new file mode 100644 index 00000000..bb1e97ee --- /dev/null +++ b/frontend/src/components/common/VisuallyHidden/VisuallyHidden.styled.ts @@ -0,0 +1,14 @@ +import styled from "@emotion/styled"; + +export const Layout = styled.div` + overflow: hidden; + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + + white-space: nowrap; + clip: rect(0, 0, 0, 0); +`; diff --git a/frontend/src/components/common/VisuallyHidden/VisuallyHidden.tsx b/frontend/src/components/common/VisuallyHidden/VisuallyHidden.tsx new file mode 100644 index 00000000..ece8dc66 --- /dev/null +++ b/frontend/src/components/common/VisuallyHidden/VisuallyHidden.tsx @@ -0,0 +1,7 @@ +import * as S from "./VisuallyHidden.styled"; + +const VisuallyHidden = ({ children, ...props }: React.PropsWithChildren) => { + return {children}; +}; + +export default VisuallyHidden; diff --git a/frontend/src/components/pages/login/LoginPage.tsx b/frontend/src/components/pages/login/LoginPage.tsx index c2685151..78a57e92 100644 --- a/frontend/src/components/pages/login/LoginPage.tsx +++ b/frontend/src/components/pages/login/LoginPage.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { Text } from "@components/common"; @@ -6,6 +6,7 @@ import { ExcitedTturi } from "@assets/gif"; import { KakaoSymbol } from "@assets/svg"; import * as S from "./LoginPage.styled"; +import { GREETING_MAIN_TEXT, GREETING_SUB_TEXT, KAKAO_LABEL, TTURI } from "./contants"; declare global { interface Window { @@ -17,15 +18,14 @@ declare global { const kakao = window.Kakao; const LoginPage = () => { - const TTURI = "뚜리"; - const GREETING_MAIN_TEXT = "투룻에 온 걸 환영해요!"; - const GREETING_SUB_TEXT = "To your route, touroot!"; - const KAKAO_LABEL = "카카오 로그인"; + const loginButtonRef = useRef(null); useEffect(() => { if (!kakao?.isInitialized()) { kakao?.init(process.env.JAVASCRIPT_KEY); } + + loginButtonRef.current && loginButtonRef.current.focus(); }, []); const handleKakaoLogin = () => { @@ -46,7 +46,7 @@ const LoginPage = () => { - + {KAKAO_LABEL} diff --git a/frontend/src/components/pages/login/contants.ts b/frontend/src/components/pages/login/contants.ts new file mode 100644 index 00000000..dab8fe5d --- /dev/null +++ b/frontend/src/components/pages/login/contants.ts @@ -0,0 +1,4 @@ +export const TTURI = "뚜리"; +export const GREETING_MAIN_TEXT = "투룻에 온 걸 환영해요!"; +export const GREETING_SUB_TEXT = "To your route, touroot!"; +export const KAKAO_LABEL = "카카오 로그인"; diff --git a/frontend/src/components/pages/main/MainPage.styled.ts b/frontend/src/components/pages/main/MainPage.styled.ts index bc6fa761..68eb62ca 100644 --- a/frontend/src/components/pages/main/MainPage.styled.ts +++ b/frontend/src/components/pages/main/MainPage.styled.ts @@ -42,7 +42,7 @@ export const TagsContainer = styled.div` gap: ${({ theme }) => theme.spacing.s}; `; -export const SingleSelectionTagsContainer = styled.ul` +export const SingleSelectionTagsContainer = styled.div` display: flex; gap: ${({ theme }) => theme.spacing.s}; @@ -85,7 +85,7 @@ export const MainPageTraveloguesList = styled.ul` gap: ${({ theme }) => theme.spacing.m}; `; -export const OptionContainer = styled.div` +export const OptionContainer = styled.button` display: flex; justify-content: space-between; @@ -119,3 +119,26 @@ export const selectedOptionStyle = css` export const unselectedOptionStyle = css` color: ${theme.colors.text.secondary}; `; + +export const MainPageList = styled.li` + width: 100%; +`; + +export const FetchButton = styled.button` + position: fixed; + bottom: 0; + left: 0; + z-index: ${({ theme }) => theme.zIndex.floating}; + padding: ${({ theme }) => theme.spacing.xs}; + + background-color: ${PRIMITIVE_COLORS.black}; + + color: ${PRIMITIVE_COLORS.white}; + + transform: translateY(100%); + transition: transform 0.3s; + + &:focus { + transform: translateY(0); + } +`; diff --git a/frontend/src/components/pages/main/MainPage.tsx b/frontend/src/components/pages/main/MainPage.tsx index 2ded8c84..85803604 100644 --- a/frontend/src/components/pages/main/MainPage.tsx +++ b/frontend/src/components/pages/main/MainPage.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; + import useInfiniteTravelogues from "@queries/useInfiniteTravelogues"; import { @@ -8,17 +10,21 @@ import { SingleSelectionTagModalBottomSheet, Text, } from "@components/common"; +import VisuallyHidden from "@components/common/VisuallyHidden/VisuallyHidden"; import TravelogueCard from "@components/pages/main/TravelogueCard/TravelogueCard"; import { useDragScroll } from "@hooks/useDragScroll"; import useIntersectionObserver from "@hooks/useIntersectionObserver"; import useMultiSelectionTag from "@hooks/useMultiSelectionTag"; import useSingleSelectionTag from "@hooks/useSingleSelectionTag"; +import useTravelogueCardFocus from "@hooks/useTravelogueCardFocus"; import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; import { FORM_VALIDATIONS_MAP } from "@constants/formValidation"; import { STORAGE_KEYS_MAP } from "@constants/storage"; +import { removeEmoji } from "@utils/removeEmojis"; + import theme from "@styles/theme"; import { @@ -40,18 +46,25 @@ const MainPage = () => { STORAGE_KEYS_MAP.mainPageTravelPeriod, ); - const { travelogues, status, fetchNextPage, isPaused, error } = useInfiniteTravelogues({ - selectedTagIDs, - selectedSortingOption: sorting.selectedOption, - selectedTravelPeriodOption: travelPeriod.selectedOption, - }); + const { travelogues, status, fetchNextPage, isPaused, error, isFetchingNextPage } = + useInfiniteTravelogues({ + selectedTagIDs, + selectedSortingOption: sorting.selectedOption, + selectedTravelPeriodOption: travelPeriod.selectedOption, + }); const { scrollRef, onMouseDown, onMouseMove, onMouseUp } = useDragScroll(); - const { lastElementRef } = useIntersectionObserver(fetchNextPage); + const [isFocused, setIsFocused] = useState(false); + const hasTravelogue = travelogues.length > 0; + const [tagSelectionAnnouncement, setTagSelectionAnnouncement] = useState(""); + const [announcement, setAnnouncement] = useState(""); + + const cardRefs = useTravelogueCardFocus(isFetchingNextPage); + if (isPaused) { alert(ERROR_MESSAGE_MAP.network); } @@ -66,14 +79,16 @@ const MainPage = () => { 지금 뜨고 있는 여행기 - 다른 이들의 여행을 구경해보세요.{" "} - (태그는 최대 {FORM_VALIDATIONS_MAP.tags.maxCount}개까지 가능해요!) + 다른 이들의 여행을 구경해보세요. ( 태그는 최대 {FORM_VALIDATIONS_MAP.tags.maxCount} + 개까지 가능해요! ) { { onMouseUp={onMouseUp} onMouseMove={onMouseMove} > - {sortedTags.map((tag, index) => ( - handleClickTag(tag.id)} - /> - ))} + {sortedTags.map((tag, index) => { + const isSelected = selectedTagIDs.includes(tag.id); + const tagName = removeEmoji(tag.tag); + + return ( +
  • + { + handleClickTag(tag.id); + setTagSelectionAnnouncement( + isSelected + ? `${tagName} 태그가 선택 해제되었습니다.` + : `${tagName} 태그가 선택되었습니다.`, + ); + }} + aria-label={`${tagName} 태그`} + /> +
  • + ); + })} + {tagSelectionAnnouncement}
    @@ -127,79 +161,116 @@ const MainPage = () => { )} {status === "success" && ( - - {hasTravelogue ? ( - travelogues.map( - ({ authorProfileUrl, authorNickname, id, title, thumbnail, likeCount, tags }) => ( - - ), - ) - ) : ( - - - - )} - + <> + {announcement} + + {hasTravelogue ? ( + travelogues.map( + ( + { authorProfileUrl, authorNickname, id, title, thumbnail, likeCount, tags }, + index, + ) => ( + + (cardRefs.current[index] = el)} + key={index} + travelogueOverview={{ + authorProfileUrl, + id, + title, + thumbnail, + likeCount, + authorNickname, + tags, + }} + /> + + ), + ) + ) : ( + + + + )} + + )} - - - setIsFocused(true)} + onBlur={() => setIsFocused(false)} + onClick={async () => { + await fetchNextPage(); + setAnnouncement("새로운 여행기가 로드되었습니다."); + }} + aria-label="더 많은 여행기 불러오기" > - {SORTING_OPTIONS.map((option, index) => ( - sorting.handleClickOption(option)}> - {option === sorting.selectedOption ? ( - <> - + 더 불러오기 + + + {!isFocused && } + + + + {sorting.isModalOpen + ? "여행기 정렬 모달이 열렸습니다." + : "여행기 정렬 모달이 닫혔습니다."} + + {sorting.isModalOpen && ( + + {SORTING_OPTIONS.map((option, index) => ( + sorting.handleClickOption(option)}> + {option === sorting.selectedOption ? ( + <> + + {SORTING_OPTIONS_MAP[option]} + + + + ) : ( + {SORTING_OPTIONS_MAP[option]} - - - ) : ( - - {SORTING_OPTIONS_MAP[option]} - - )} - - ))} - - - - {TRAVEL_PERIOD_OPTIONS.map((option, index) => ( - travelPeriod.handleClickOption(option)}> - {option === travelPeriod.selectedOption ? ( - <> - + )} + + ))} + + )} + + + + {travelPeriod.isModalOpen + ? "여행기 필터 모달이 열렸습니다." + : "여행기 필터 모달이 닫혔습니다."} + + {travelPeriod.isModalOpen && ( + + {TRAVEL_PERIOD_OPTIONS.map((option, index) => ( + travelPeriod.handleClickOption(option)}> + {option === travelPeriod.selectedOption ? ( + <> + + {TRAVEL_PERIOD_OPTIONS_MAP[option]} + + + + ) : ( + {TRAVEL_PERIOD_OPTIONS_MAP[option]} - - - ) : ( - - {TRAVEL_PERIOD_OPTIONS_MAP[option]} - - )} - - ))} - + )} + + ))} + + )} ); diff --git a/frontend/src/components/pages/main/TravelogueCard/TravelogueCard.styled.ts b/frontend/src/components/pages/main/TravelogueCard/TravelogueCard.styled.ts index b70507ce..a2891f66 100644 --- a/frontend/src/components/pages/main/TravelogueCard/TravelogueCard.styled.ts +++ b/frontend/src/components/pages/main/TravelogueCard/TravelogueCard.styled.ts @@ -1,8 +1,9 @@ import styled from "@emotion/styled"; -export const TravelogueCardLayout = styled.li` +export const TravelogueCardButton = styled.button` display: flex; flex-direction: column; + width: 100%; max-width: calc(48rem - 3.2rem); padding: 1.6rem; border: 1px solid ${({ theme }) => theme.colors.border}; @@ -16,8 +17,6 @@ export const TravelogueCardLayout = styled.li` &:hover { transform: translateY(-5px); - - border-radius: 8px; } `; diff --git a/frontend/src/components/pages/main/TravelogueCard/TravelogueCard.tsx b/frontend/src/components/pages/main/TravelogueCard/TravelogueCard.tsx index 2f003c44..332b7f2b 100644 --- a/frontend/src/components/pages/main/TravelogueCard/TravelogueCard.tsx +++ b/frontend/src/components/pages/main/TravelogueCard/TravelogueCard.tsx @@ -1,86 +1,97 @@ +import React from "react"; import { useNavigate } from "react-router-dom"; import { TravelogueResponse } from "@type/domain/travelogue"; -import { AvatarCircle, Chip, FallbackImage, IconButton, Text } from "@components/common"; +import { AvatarCircle, Chip, FallbackImage, Icon, Text } from "@components/common"; import useImageError from "@hooks/useImageError"; import { CYPRESS_DATA_MAP } from "@constants/cypress"; import { ROUTE_PATHS_MAP } from "@constants/route"; +import { removeEmoji } from "@utils/removeEmojis"; + import * as S from "./TravelogueCard.styled"; -interface TravelogueCardProps { +interface TravelogueCardProps extends React.ComponentPropsWithoutRef<"button"> { travelogueOverview: Pick< TravelogueResponse, "id" | "authorProfileUrl" | "title" | "thumbnail" | "authorNickname" | "likeCount" | "tags" >; } -const TravelogueCard = ({ - travelogueOverview: { - id, - authorProfileUrl, - title, - thumbnail, - authorNickname, - likeCount = 0, - tags, - }, -}: TravelogueCardProps) => { - const navigate = useNavigate(); - - const { imageError, handleImageError } = useImageError({ imageUrl: thumbnail }); - - const handleCardClick = () => { - navigate(ROUTE_PATHS_MAP.travelogue(id)); - }; - - const handleLikeClick = (e: React.MouseEvent) => { - e.stopPropagation(); - }; - - return ( - - - {title} - - - - {!imageError ? ( - - ) : ( - - )} - - - - - - {authorNickname} - - - - - {likeCount} - - - - - {tags.map((tag) => ( - - ))} - - - ); +const getCardAriaLabel = ({ + title, + authorNickname, + likeCount, + tags, +}: Pick) => { + const tagText = tags.map((tag) => removeEmoji(tag.tag)); + const tagPart = tagText ? `태그: ${tagText}` : ""; + + return `${removeEmoji(title)} 여행기. ${authorNickname} 작성. 좋아요 수: ${likeCount}개. ${tagPart}`; }; +const TravelogueCard = React.forwardRef( + ({ travelogueOverview, ...props }, ref) => { + const { + id, + authorProfileUrl, + title, + thumbnail, + authorNickname, + likeCount = 0, + tags, + } = travelogueOverview; + + const navigate = useNavigate(); + const { imageError, handleImageError } = useImageError({ imageUrl: thumbnail }); + + const handleCardClick = () => { + navigate(ROUTE_PATHS_MAP.travelogue(id)); + }; + + return ( + + + {title} + + + + {!imageError ? ( + + ) : ( + + )} + + + + + + {authorNickname} + + + + + {likeCount} + + + + + {tags.map((tag) => ( + + ))} + + + ); + }, +); + export default TravelogueCard; diff --git a/frontend/src/hooks/useKeyDown.ts b/frontend/src/hooks/useKeyDown.ts new file mode 100644 index 00000000..77669aa4 --- /dev/null +++ b/frontend/src/hooks/useKeyDown.ts @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from "react"; + +import type { + ArrowKey, + Direction, + HorizontalKey, + IncrementValue, + KeyActions, + VerticalKey, +} from "@type/domain/keyboardNavigation"; + +interface UseKeyDownProps { + isOpen: boolean; + direction?: Direction; +} +const isVerticalKey = (key: unknown): key is VerticalKey => { + return key === "ArrowDown" || key === "ArrowUp"; +}; + +const isHorizontalKey = (key: unknown): key is HorizontalKey => { + return key === "ArrowRight" || key === "ArrowLeft"; +}; + +const isArrowKey = (key: unknown): key is ArrowKey => { + return isVerticalKey(key) || isHorizontalKey(key); +}; + +const keyActions: KeyActions = { + vertical: { + ArrowDown: 1, + ArrowUp: -1, + }, + horizontal: { + ArrowRight: 1, + ArrowLeft: -1, + }, +}; + +const useKeyDown = ({ isOpen, direction = "vertical" }: UseKeyDownProps) => { + const modalRef = useRef(null); + const focusableElements = useRef([]); + const [currentFocusIndex, setCurrentFocusIndex] = useState(-1); + + // useEffect(() => { + // if (isOpen && modalRef.current) { + // focusableElements.current = Array.from( + // modalRef.current.querySelectorAll( + // 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + // ), + // ); + // setCurrentFocusIndex(0); + // focusableElements.current[0]?.focus(); + // } + // }, [isOpen]); + + useEffect(() => { + if (isOpen && modalRef.current) { + const observer = new MutationObserver(() => { + focusableElements.current = Array.from( + modalRef.current!.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ), + ); + }); + + observer.observe(modalRef.current, { childList: true, subtree: true }); + + return () => observer.disconnect(); + } + }, [isOpen]); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!isOpen) return; + + const { key } = event; + + if (!isArrowKey(key)) return; + + event.preventDefault(); + + let increment: IncrementValue = 1; + + if (direction === "vertical" && isVerticalKey(key)) { + increment = keyActions.vertical[key]; + } else if (direction === "horizontal" && isHorizontalKey(key)) { + increment = keyActions.horizontal[key]; + } + + const length = focusableElements.current.length; + const nextIndex = (currentFocusIndex + increment + length) % length; + + if (nextIndex !== currentFocusIndex) { + setCurrentFocusIndex(nextIndex); + focusableElements.current[nextIndex]?.focus(); + } + }; + + return { modalRef, handleKeyDown }; +}; + +export default useKeyDown; diff --git a/frontend/src/hooks/useTravelogueCardFocus.ts b/frontend/src/hooks/useTravelogueCardFocus.ts new file mode 100644 index 00000000..0ee3bc04 --- /dev/null +++ b/frontend/src/hooks/useTravelogueCardFocus.ts @@ -0,0 +1,22 @@ +import { useEffect, useRef } from "react"; + +const DATA_LOAD_COUNT = 5; + +const useTravelogueCardFocus = (isFetchingNextPage: boolean) => { + const cardRefs = useRef<(HTMLButtonElement | null)[]>([]); + + useEffect(() => { + if (!isFetchingNextPage && cardRefs.current.length > 0) { + const focusIndex = cardRefs.current.findLastIndex( + (_, index) => index > 0 && index % DATA_LOAD_COUNT === 0, + ); + if (focusIndex !== -1 && cardRefs.current[focusIndex]) { + cardRefs.current[focusIndex]?.focus(); + } + } + }, [isFetchingNextPage]); + + return cardRefs; +}; + +export default useTravelogueCardFocus; diff --git a/frontend/src/mocks/handlers/index.ts b/frontend/src/mocks/handlers/index.ts index efd11fdf..6e70d154 100644 --- a/frontend/src/mocks/handlers/index.ts +++ b/frontend/src/mocks/handlers/index.ts @@ -1,11 +1,11 @@ -import { modifyMemberNicknameHandler } from "@mocks/handlers/modifyMemberNicknameHandler"; -import { travelPlanHandler } from "@mocks/handlers/travelPlanHandler"; -import { travelPlanRegisterHandler } from "@mocks/handlers/travelPlanRegisterHandler"; -import { travelogueInfiniteHandler } from "@mocks/handlers/travelogueInfiniteHandler"; +// import { modifyMemberNicknameHandler } from "@mocks/handlers/modifyMemberNicknameHandler"; +// import { travelPlanHandler } from "@mocks/handlers/travelPlanHandler"; +// import { travelPlanRegisterHandler } from "@mocks/handlers/travelPlanRegisterHandler"; +// import { travelogueInfiniteHandler } from "@mocks/handlers/travelogueInfiniteHandler"; export const handlers = [ - travelogueInfiniteHandler, - travelPlanHandler, - travelPlanRegisterHandler, - modifyMemberNicknameHandler, + // travelogueInfiniteHandler, + // travelPlanHandler, + // travelPlanRegisterHandler, + // modifyMemberNicknameHandler, ]; diff --git a/frontend/src/types/domain/keyboardNavigation.ts b/frontend/src/types/domain/keyboardNavigation.ts new file mode 100644 index 00000000..fed08711 --- /dev/null +++ b/frontend/src/types/domain/keyboardNavigation.ts @@ -0,0 +1,10 @@ +export type Direction = "vertical" | "horizontal"; +export type VerticalKey = "ArrowDown" | "ArrowUp"; +export type HorizontalKey = "ArrowRight" | "ArrowLeft"; +export type ArrowKey = VerticalKey | HorizontalKey; +export type IncrementValue = 1 | -1; + +export interface KeyActions { + vertical: Record; + horizontal: Record; +} diff --git a/frontend/src/utils/removeEmojis.ts b/frontend/src/utils/removeEmojis.ts new file mode 100644 index 00000000..a8e5ae6c --- /dev/null +++ b/frontend/src/utils/removeEmojis.ts @@ -0,0 +1,11 @@ +export const removeEmoji = (string: string) => + string.replace(/(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)/gu, "").trim(); + +const joinStringsWithoutEmojis = (strings: string[]) => + strings.map((string) => removeEmoji(string)).join(", "); + +const removeEmojis = (stringArray: string[]) => { + return joinStringsWithoutEmojis(stringArray); +}; + +export default removeEmojis;