diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ThemeItemCard.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ThemeItemCard.jsx new file mode 100644 index 00000000..f2641c76 --- /dev/null +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ThemeItemCard.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Spottable from '@enact/spotlight/Spottable'; +import css from './ThemeItemCard.module.less'; + +const SpottableDiv = Spottable('div'); + +export default function ThemeItemCard({ + onClick, + onMouseDown, + spotlightId = 'theme-open-button', + className = '', + children = 'THEME ITEM', + iconSrc = null, +}) { + return ( + + + {children} + {iconSrc && ( + + + + )} + + + ); +} + +ThemeItemCard.propTypes = { + onClick: PropTypes.func, + onMouseDown: PropTypes.func, + spotlightId: PropTypes.string, + className: PropTypes.string, + children: PropTypes.node, + iconSrc: PropTypes.string, +}; diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.jsx index 700b23b0..9897c544 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.jsx +++ b/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.jsx @@ -1,14 +1,14 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { useDispatch } from 'react-redux'; import Spotlight from '@enact/spotlight'; import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator'; -import Spottable from '@enact/spotlight/Spottable'; import arrowDownIcon from '../../../../assets/images/icons/ic-arrow-down.svg'; import { updatePanel } from '../../../actions/panelActions'; import TButton from '../../../components/TButton/TButton'; +import ThemeItemCard from './ThemeItemCard'; import { LOG_CONTEXT_NAME, LOG_MESSAGE_ID, panel_names } from '../../../utils/Config'; import { $L } from '../../../utils/helperMethods'; import css from './ThemeContents.module.less'; @@ -25,30 +25,23 @@ const Container = SpotlightContainerDecorator( 'div' ); -const ItemsContainer = SpotlightContainerDecorator( - { - enterTo: 'default-element', - preserveId: true, - spotlightDirection: 'horizontal', // 리스트 내 좌/우 이동 - spotlightRestrict: 'self-only', - }, - 'div' -); - const ButtonContainer = SpotlightContainerDecorator( { - enterTo: 'default-element', + enterTo: 'last-focused', preserveId: true, spotlightDirection: 'horizontal', - // 버튼에서 아래 리스트로 포커스 이동해야 하므로 self-only는 사용하지 않는다 }, 'div' ); -const SpottableItemCard = Spottable({ - allowDisabledFocus: true, - noAutoFocus: true, -})('div'); +const ItemsContainer = SpotlightContainerDecorator( + { + enterTo: 'last-focused', + preserveId: true, + spotlightDirection: 'horizontal', + }, + 'div' +); export default function ThemeContents({ themeItems, @@ -66,7 +59,6 @@ export default function ThemeContents({ const dispatch = useDispatch(); const isClickBlocked = useRef(false); const blockTimeoutRef = useRef(null); - const [focusedIndex, setFocusedIndex] = useState(-1); // Mock 데이터 const mockItems = [ @@ -141,6 +133,95 @@ export default function ThemeContents({ } }, [onThemeItemClose]); + const renderItem = useCallback( + (item, index) => { + console.log('[Theme] renderItem index', index, 'displayItems size', displayItems?.length); + const { + prdtId, + prdtNm, + prdtImgPath, + salePrice, + originalPrice, + patnrLogoPath, + patncNm, + showId, + catNm, + energyLabels, + } = item; + + const spotlightItemId = `theme-toast-item-${index}`; + + const handleItemClick = () => { + const params = { + tabTitle: tabTitle[tabIndex], + productId: prdtId, + productTitle: prdtNm, + showType: panelInfo?.shptmBanrTpNm, + category: catNm, + partner: patncNm, + contextName: LOG_CONTEXT_NAME.PRODUCT, + messageId: LOG_MESSAGE_ID.CONTENTCLICK, + }; + dispatch(sendLogTotalRecommend(params)); + + if (isClickBlocked.current) { + return; + } + + isClickBlocked.current = true; + + if (blockTimeoutRef.current) { + clearTimeout(blockTimeoutRef.current); + } + + blockTimeoutRef.current = setTimeout(() => { + isClickBlocked.current = false; + blockTimeoutRef.current = null; + }, 600); + + if (!prdtId) return; + + setSelectedIndex(index); + dispatch( + updatePanel({ + name: panel_names.PLAYER_PANEL, + panelInfo: { + prdtId, + showId, + shptmBanrTpNm: 'THEME', + isUpdatedByClick: true, + }, + }) + ); + }; + + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + handleItemClick(); + } + }} + onFocus={() => { + handleItemFocus?.(index); + }} + onMouseEnter={() => Spotlight.focus(spotlightItemId)} + /> + ); + }, + [displayItems, tabTitle, tabIndex, panelInfo, dispatch] + ); + // cleanup useEffect useEffect(() => { return () => { @@ -155,20 +236,37 @@ export default function ThemeContents({ Spotlight.focus('theme-contents-close-button'); }, []); - // 키 이동 보정: 위/아래 이동 시 THEME ITEM 버튼 <-> 첫 번째 아이템으로 확실히 연결 + // 키 이동 보정: 위/아래/좌우 이동 설정 useEffect(() => { + console.log('[Theme] focus mapping displayItems', displayItems, displayItems?.length); if (!displayItems || displayItems.length === 0) return; const firstItemId = 'theme-toast-item-0'; + const lastIndex = displayItems.length - 1; + + // THEME ITEM 버튼과 첫 번째 아이템 연결 Spotlight.set('theme-contents-close-button', { next: { down: firstItemId }, }); - displayItems.slice(0, 5).forEach((_, index) => { + // 모든 아이템의 위/아래/좌/우 네비게이션 설정 + displayItems.forEach((_, index) => { const itemId = `theme-toast-item-${index}`; - Spotlight.set(itemId, { - next: { up: 'theme-contents-close-button' }, - }); + const nextItemId = index < lastIndex ? `theme-toast-item-${index + 1}` : null; + const prevItemId = index > 0 ? `theme-toast-item-${index - 1}` : null; + + const nextConfig = { + up: 'theme-contents-close-button', + }; + + if (nextItemId) { + nextConfig.right = nextItemId; + } + if (prevItemId) { + nextConfig.left = prevItemId; + } + + Spotlight.set(itemId, { next: nextConfig }); }); }, [displayItems]); @@ -195,106 +293,7 @@ export default function ThemeContents({ spotlightDefaultElement="theme-toast-item-0" > {displayItems && displayItems.length > 0 ? ( - displayItems.slice(0, 5).map((item, index) => { - const { - prdtId, - prdtNm, - prdtImgPath, - salePrice, - originalPrice, - patnrLogoPath, - patncNm, - showId, - catNm, - energyLabels, - } = item; - - const spotlightItemId = `theme-toast-item-${index}`; - - const handleItemClick = () => { - const params = { - tabTitle: tabTitle[tabIndex], - productId: prdtId, - productTitle: prdtNm, - showType: panelInfo?.shptmBanrTpNm, - category: catNm, - partner: patncNm, - contextName: LOG_CONTEXT_NAME.PRODUCT, - messageId: LOG_MESSAGE_ID.CONTENTCLICK, - }; - dispatch(sendLogTotalRecommend(params)); - - if (isClickBlocked.current) { - return; - } - - isClickBlocked.current = true; - - if (blockTimeoutRef.current) { - clearTimeout(blockTimeoutRef.current); - } - - blockTimeoutRef.current = setTimeout(() => { - isClickBlocked.current = false; - blockTimeoutRef.current = null; - }, 600); - - if (!prdtId) return; - - setSelectedIndex(index); - dispatch( - updatePanel({ - name: panel_names.PLAYER_PANEL, - panelInfo: { - prdtId, - showId, - shptmBanrTpNm: 'THEME', - isUpdatedByClick: true, - }, - }) - ); - }; - - return ( - { - if (e.key === 'Enter' || e.key === ' ') { - handleItemClick(); - } - }} - onFocus={() => { - setFocusedIndex(index); - handleItemFocus?.(index); - }} - onBlur={() => setFocusedIndex(-1)} - onMouseEnter={() => Spotlight.focus(spotlightItemId)} - > - - - {prdtNm} - - {salePrice} - {originalPrice} - - {energyLabels && energyLabels.length > 0 && ( - - {energyLabels.map((label, labelIndex) => ( - - {/* 에너지 라벨 렌더링 */} - {label} - - ))} - - )} - - - ); - }) + displayItems.map((item, index) => renderItem(item, index)) ) : ( No items )} diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.module.less index a1537559..61d4c7fb 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.module.less +++ b/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.module.less @@ -4,7 +4,7 @@ // Toast wrapper 스타일 .toastWrapper { width: 100%; - height: 390px; + height: 450px; // padding(120) + button(60) + gap(30) + items(240) = 450px display: inline-flex; flex-direction: column; justify-content: flex-start; @@ -31,6 +31,8 @@ } .itemsWrapper { + width: 100%; + height: 180px; display: inline-flex; flex-direction: row; gap: 18px; @@ -39,40 +41,38 @@ flex-wrap: nowrap; overflow-x: auto; overflow-y: visible; // 포커스 테두리 보존 - flex: none; - padding: 4px; // 포커스 테두리(4px box-shadow) 표시 공간 확보 + flex-shrink: 0; + scrollbar-width: none; // Firefox 스크롤바 숨김 + + &::-webkit-scrollbar { + display: none; // WebKit 스크롤바 숨김 + } } .itemCard { width: 470px; height: 180px; padding: 30px; + box-sizing: border-box; background: linear-gradient(0deg, 0%, 100%), #2C2C2C; border-radius: 12px; justify-content: flex-start; align-items: flex-start; gap: 15px; - display: inline-flex; + display: flex; flex-shrink: 0; cursor: pointer; outline: none; transition: all 0.2s ease; position: relative; - &:focus { - outline: none; - box-shadow: 0 0 0 4px @PRIMARY_COLOR_RED; - } - + &:focus, &:global(.spotlight) { box-shadow: 0 0 0 4px @PRIMARY_COLOR_RED; + z-index: 2; } } -.focused { - box-shadow: 0 0 0 4px @PRIMARY_COLOR_RED; -} - .itemImage { width: 120px; height: 120px; diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeItemCard.figma.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeItemCard.figma.jsx new file mode 100644 index 00000000..542f9231 --- /dev/null +++ b/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeItemCard.figma.jsx @@ -0,0 +1,322 @@ + + + + + Orlgami Removable Connecting Rack 2 -pack + + + + $32.98 + + + $150.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +; diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeItemCard.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeItemCard.jsx new file mode 100644 index 00000000..8bcec792 --- /dev/null +++ b/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeItemCard.jsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import Spotlight from '@enact/spotlight'; +import Spottable from '@enact/spotlight/Spottable'; +import css from './ThemeItemCard.module.less'; + +const SpottableDiv = Spottable('div'); + +export default function ThemeItemCard({ + prdtId, + prdtNm, + prdtImgPath, + salePrice, + originalPrice, + energyLabels, + onClick, + onFocus, + onMouseEnter, + onKeyDown, + spotlightId, + dataSpotlightDefault, + onFocused, +}) { + const [isFocused, setIsFocused] = useState(false); + + const handleFocus = (e) => { + setIsFocused(true); + onFocus?.(e); + onFocused?.(true); + }; + + const handleBlur = (e) => { + setIsFocused(false); + onFocused?.(false); + }; + + return ( + + + + {prdtNm} + + {salePrice} + {originalPrice} + + {energyLabels && energyLabels.length > 0 && ( + + {energyLabels.map((label, labelIndex) => ( + + {label} + + ))} + + )} + + + ); +} + +ThemeItemCard.propTypes = { + prdtId: PropTypes.string, + prdtNm: PropTypes.string, + prdtImgPath: PropTypes.string, + salePrice: PropTypes.string, + originalPrice: PropTypes.string, + energyLabels: PropTypes.array, + onClick: PropTypes.func, + onFocus: PropTypes.func, + onFocused: PropTypes.func, + onMouseEnter: PropTypes.func, + onKeyDown: PropTypes.func, + spotlightId: PropTypes.string, + dataSpotlightDefault: PropTypes.bool, +}; diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeItemCard.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeItemCard.module.less new file mode 100644 index 00000000..69250365 --- /dev/null +++ b/com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeItemCard.module.less @@ -0,0 +1,168 @@ +@import "../../../style/CommonStyle.module.less"; +@import "../../../style/utils.module.less"; + +.itemCard { + width: 470px; + height: 180px; + padding: 30px; + box-sizing: border-box; + background: linear-gradient(0deg, 0%, 100%), #2C2C2C; + border-radius: 12px; + justify-content: flex-start; + align-items: flex-start; + gap: 15px; + display: flex; + flex-shrink: 0; + cursor: pointer; + outline: none; + transition: all 0.2s ease; + position: relative; + + &.focused { + outline: 4px solid @PRIMARY_COLOR_RED; + outline-offset: -4px; + } + + &:focus, + &:global(.spotlight) { + outline: 4px solid @PRIMARY_COLOR_RED; + outline-offset: -4px; + z-index: 2; + } +} + +.itemImage { + width: 120px; + height: 120px; + padding-left: 0.41px; + padding-right: 0.41px; + padding-top: 0.51px; + padding-bottom: 0.51px; + flex-shrink: 0; +} + +.itemInfo { + flex: 1 1 0; + align-self: stretch; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 10px; + display: inline-flex; +} + +.itemName { + align-self: stretch; + color: #EAEAEA; + font-size: 20px; + font-family: @baseFont; + font-weight: 400; + line-height: 25px; + word-wrap: break-word; +} + +.itemPrice { + padding-top: 1px; + padding-bottom: 1px; + justify-content: flex-start; + align-items: center; + gap: 5px; + display: inline-flex; +} + +.salePrice { + color: #C70850; + font-size: 20px; + font-family: @baseFont; + font-weight: 700; + line-height: 16.67; + word-wrap: break-word; +} + +.originalPrice { + color: #C2C2C2; + font-size: 16px; + font-family: @baseFont; + font-weight: 400; + text-decoration: line-through; + line-height: 16.67; + word-wrap: break-word; +} + +.energyLabels { + justify-content: flex-end; + align-items: flex-end; + gap: 5px; + display: inline-flex; +} + +.energyLabel { + width: 54px; + height: 30px; + position: relative; + overflow: hidden; +} + +// Button 스타일 (기존) +.themeItemCard { + align-self: stretch; + height: 60px; + background: rgba(255, 255, 255, 0.05); // 기본 회색 배경 + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + padding-left: 30px; + padding-right: 30px; + padding-top: 20px; + padding-bottom: 20px; + overflow: hidden; + cursor: pointer; + + &:focus { + background: @PRIMARY_COLOR_RED; // 포커스시만 빨간색 + } + + &.active { + border: 4px solid #4f172c; + background: #4f172c; + } + + // 내부 콘텐츠 컨테이너 + .themeItemCardContent { + display: flex; + align-items: center; + justify-content: center; + gap: 15px; // 텍스트와 아이콘 사이 간격 + width: 100%; + } + + // 텍스트 스타일 + .themeItemCardText { + color: white; + font-size: 25px; + font-family: 'LG Smart UI'; + font-weight: 400; // Regular (Bold가 아님) + line-height: 35; + white-space: nowrap; + flex: 1; // 남은 공간 차지 + text-align: center; + } + + // 아이콘 스타일 + .themeItemCardIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 26.25px; + height: 15.63px; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + } +} \ No newline at end of file