[251123] fix: DetailPanel ThemeContent-2

🕐 커밋 시간: 2025. 11. 23. 12:12:36

📊 변경 통계:
  • 총 파일: 6개
  • 추가: +132줄
  • 삭제: -133줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ThemeItemCard.jsx
  + com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeItemCard.figma.jsx
  + com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeItemCard.jsx
  + com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeItemCard.module.less

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.jsx
  ~ com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.module.less

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.jsx (javascript):
     Added: handleItemClick()
    🔄 Modified: SpotlightContainerDecorator()
     Deleted: handleItemClick()
  📄 com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeItemCard.jsx (javascript):
     Added: handleFocus(), handleBlur()
  📄 com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeItemCard.module.less (unknown):
     Added: gradient(), global()

Performance: 코드 최적화로 성능 개선 기대
This commit is contained in:
2025-11-23 12:12:36 +09:00
parent 8d45d89d09
commit e44bcaf19f
6 changed files with 751 additions and 137 deletions

View File

@@ -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 (
<SpottableDiv
className={`${css.themeItemCard} ${className}`}
onClick={onClick}
onMouseDown={onMouseDown}
spotlightId={spotlightId}
>
<div className={css.themeItemCardContent}>
<span className={css.themeItemCardText}>{children}</span>
{iconSrc && (
<div className={css.themeItemCardIcon}>
<img src={iconSrc} alt="arrow down" />
</div>
)}
</div>
</SpottableDiv>
);
}
ThemeItemCard.propTypes = {
onClick: PropTypes.func,
onMouseDown: PropTypes.func,
spotlightId: PropTypes.string,
className: PropTypes.string,
children: PropTypes.node,
iconSrc: PropTypes.string,
};

View File

@@ -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,61 +133,9 @@ export default function ThemeContents({
}
}, [onThemeItemClose]);
// cleanup useEffect
useEffect(() => {
return () => {
if (blockTimeoutRef.current) {
clearTimeout(blockTimeoutRef.current);
}
};
}, []);
// 토스트가 열리면 닫기(=THEME ITEM) 버튼부터 포커스되도록 보정
useEffect(() => {
Spotlight.focus('theme-contents-close-button');
}, []);
// 키 이동 보정: 위/아래 이동 시 THEME ITEM 버튼 <-> 첫 번째 아이템으로 확실히 연결
useEffect(() => {
if (!displayItems || displayItems.length === 0) return;
const firstItemId = 'theme-toast-item-0';
Spotlight.set('theme-contents-close-button', {
next: { down: firstItemId },
});
displayItems.slice(0, 5).forEach((_, index) => {
const itemId = `theme-toast-item-${index}`;
Spotlight.set(itemId, {
next: { up: 'theme-contents-close-button' },
});
});
}, [displayItems]);
return (
<Container
className={css.toastWrapper}
spotlightId="theme-contents-container"
spotlightDefaultElement="theme-contents-close-button"
>
<ButtonContainer className={css.topButtonWrapper} spotlightId="theme-button-container">
<TButton
className={css.themeButton}
onClick={handleThemeItemButtonClick}
spotlightId="theme-contents-close-button"
data-spotlight-default
>
<div>{$L('THEME ITEM')}</div>
<img src={arrowDownIcon} className={css.themeButtonIcon} />
</TButton>
</ButtonContainer>
<ItemsContainer
className={css.itemsWrapper}
spotlightId="theme-items-container"
spotlightDefaultElement="theme-toast-item-0"
>
{displayItems && displayItems.length > 0 ? (
displayItems.slice(0, 5).map((item, index) => {
const renderItem = useCallback(
(item, index) => {
console.log('[Theme] renderItem index', index, 'displayItems size', displayItems?.length);
const {
prdtId,
prdtNm,
@@ -256,45 +196,104 @@ export default function ThemeContents({
};
return (
<SpottableItemCard
key={prdtId}
className={`${css.itemCard} ${focusedIndex === index ? css.focused : ''}`}
<ThemeItemCard
key={prdtId || `theme-item-${index}`}
prdtId={prdtId}
prdtNm={prdtNm}
prdtImgPath={prdtImgPath}
salePrice={salePrice}
originalPrice={originalPrice}
energyLabels={energyLabels}
onClick={handleItemClick}
spotlightId={spotlightItemId}
data-spotlight-default={index === 0}
dataSpotlightDefault={index === 0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleItemClick();
}
}}
onFocus={() => {
setFocusedIndex(index);
handleItemFocus?.(index);
}}
onBlur={() => setFocusedIndex(-1)}
onMouseEnter={() => Spotlight.focus(spotlightItemId)}
>
<img src={prdtImgPath} alt={prdtId} className={css.itemImage} />
<div className={css.itemInfo}>
<div className={css.itemName}>{prdtNm}</div>
<div className={css.itemPrice}>
<span className={css.salePrice}>{salePrice}</span>
<span className={css.originalPrice}>{originalPrice}</span>
</div>
{energyLabels && energyLabels.length > 0 && (
<div className={css.energyLabels}>
{energyLabels.map((label, labelIndex) => (
<div key={labelIndex} className={css.energyLabel}>
{/* 에너지 라벨 렌더링 */}
{label}
</div>
))}
</div>
)}
</div>
</SpottableItemCard>
/>
);
})
},
[displayItems, tabTitle, tabIndex, panelInfo, dispatch]
);
// cleanup useEffect
useEffect(() => {
return () => {
if (blockTimeoutRef.current) {
clearTimeout(blockTimeoutRef.current);
}
};
}, []);
// 토스트가 열리면 닫기(=THEME ITEM) 버튼부터 포커스되도록 보정
useEffect(() => {
Spotlight.focus('theme-contents-close-button');
}, []);
// 키 이동 보정: 위/아래/좌우 이동 설정
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.forEach((_, index) => {
const itemId = `theme-toast-item-${index}`;
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]);
return (
<Container
className={css.toastWrapper}
spotlightId="theme-contents-container"
spotlightDefaultElement="theme-contents-close-button"
>
<ButtonContainer className={css.topButtonWrapper} spotlightId="theme-button-container">
<TButton
className={css.themeButton}
onClick={handleThemeItemButtonClick}
spotlightId="theme-contents-close-button"
data-spotlight-default
>
<div>{$L('THEME ITEM')}</div>
<img src={arrowDownIcon} className={css.themeButtonIcon} />
</TButton>
</ButtonContainer>
<ItemsContainer
className={css.itemsWrapper}
spotlightId="theme-items-container"
spotlightDefaultElement="theme-toast-item-0"
>
{displayItems && displayItems.length > 0 ? (
displayItems.map((item, index) => renderItem(item, index))
) : (
<div>No items</div>
)}

View File

@@ -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;

View File

@@ -0,0 +1,322 @@
<div
style={{
width: '470px',
height: '180px',
padding: 30,
background: 'linear-gradient(0deg, 0%, 100%), #2C2C2C',
borderRadius: 12,
justifyContent: 'flex-start',
alignItems: 'flex-start',
gap: 15,
display: 'inline-flex',
}}
>
<img
style={{
width: 120,
height: 120,
paddingLeft: 0.41,
paddingRight: 0.41,
paddingTop: 0.51,
paddingBottom: 0.51,
}}
src="https://placehold.co/120x120"
/>
<div
style={{
flex: '1 1 0',
alignSelf: 'stretch',
flexDirection: 'column',
justifyContent: 'flex-start',
alignItems: 'flex-start',
gap: 10,
display: 'inline-flex',
}}
>
<div
style={{
alignSelf: 'stretch',
color: '#EAEAEA',
fontSize: 20,
fontFamily: 'LG Smart UI',
fontWeight: '400',
lineHeight: 25,
wordWrap: 'break-word',
}}
>
Orlgami Removable Connecting Rack 2 -pack
</div>
<div
style={{
paddingTop: 1,
paddingBottom: 1,
justifyContent: 'flex-start',
alignItems: 'center',
gap: 5,
display: 'inline-flex',
}}
>
<div
style={{
color: '#C70850',
fontSize: 20,
fontFamily: 'LG Smart UI',
fontWeight: '700',
lineHeight: 16.67,
wordWrap: 'break-word',
}}
>
$32.98
</div>
<div
style={{
color: '#C2C2C2',
fontSize: 16,
fontFamily: 'LG Smart UI',
fontWeight: '400',
textDecoration: 'line-through',
lineHeight: 16.67,
wordWrap: 'break-word',
}}
>
$150.00
</div>
</div>
<div
style={{ justifyContent: 'flex-end', alignItems: 'flex-end', gap: 5, display: 'inline-flex' }}
>
<div style={{ width: 54, height: 30, position: 'relative', overflow: 'hidden' }}>
<div
style={{
width: 12.21,
height: 28.05,
left: 41.07,
top: 1.03,
position: 'absolute',
background: 'white',
}}
/>
<div
style={{
width: 40.83,
height: 27.81,
left: 1.11,
top: 1.05,
position: 'absolute',
background: '#BFD730',
}}
/>
<div
style={{
width: 51.71,
height: 28.23,
left: 1.18,
top: 0.84,
position: 'absolute',
outline: '1.37px #231F20 solid',
outlineOffset: '-0.68px',
}}
/>
<div
style={{
width: 6.42,
height: 7.05,
left: 44.28,
top: 2.81,
position: 'absolute',
background: '#231F20',
}}
/>
<div
style={{
width: 3.14,
height: 1.57,
left: 45.94,
top: 12.04,
position: 'absolute',
outline: '1.46px #231F20 solid',
outlineOffset: '-0.73px',
}}
/>
<div
style={{
width: 5.44,
height: 7.12,
left: 44.77,
top: 19.99,
position: 'absolute',
background: '#231F20',
}}
/>
<div style={{ width: 11.7, height: 13.88, left: 21.49, top: 8.02, position: 'absolute' }}>
<div
style={{
width: 8.95,
height: 13.52,
left: 1.37,
top: 0.18,
position: 'absolute',
background: 'white',
outline: '0.68px #231F20 solid',
outlineOffset: '-0.34px',
}}
/>
</div>
</div>
<div style={{ width: 54, height: 30, position: 'relative', overflow: 'hidden' }}>
<div
style={{
width: 12.21,
height: 28.05,
left: 41.07,
top: 1.03,
position: 'absolute',
background: 'white',
}}
/>
<div
style={{
width: 40.83,
height: 27.81,
left: 1.45,
top: 1.05,
position: 'absolute',
background: '#F37021',
}}
/>
<div
style={{
width: 51.71,
height: 28.23,
left: 1.52,
top: 0.84,
position: 'absolute',
outline: '1.37px #231F20 solid',
outlineOffset: '-0.68px',
}}
/>
<div
style={{
width: 6.42,
height: 7.05,
left: 44.62,
top: 2.81,
position: 'absolute',
background: '#231F20',
}}
/>
<div
style={{
width: 3.14,
height: 1.57,
left: 46.28,
top: 12.04,
position: 'absolute',
outline: '1.46px #231F20 solid',
outlineOffset: '-0.73px',
}}
/>
<div
style={{
width: 5.44,
height: 7.12,
left: 45.11,
top: 19.99,
position: 'absolute',
background: '#231F20',
}}
/>
<div style={{ width: 11.7, height: 13.88, left: 21.49, top: 8.02, position: 'absolute' }}>
<div
style={{
width: 7.13,
height: 13.81,
left: 2.29,
top: 0.04,
position: 'absolute',
background: 'white',
outline: '0.68px #231F20 solid',
outlineOffset: '-0.34px',
}}
/>
</div>
</div>
<div style={{ width: 54, height: 30, position: 'relative', overflow: 'hidden' }}>
<div
style={{
width: 12.21,
height: 28.05,
left: 41.07,
top: 1.03,
position: 'absolute',
background: 'white',
}}
/>
<div
style={{
width: 40.83,
height: 27.81,
left: 1.45,
top: 1.05,
position: 'absolute',
background: '#00A651',
}}
/>
<div
style={{
width: 51.71,
height: 28.23,
left: 1.52,
top: 0.84,
position: 'absolute',
outline: '1.37px #231F20 solid',
outlineOffset: '-0.68px',
}}
/>
<div
style={{
width: 6.42,
height: 7.05,
left: 44.62,
top: 2.81,
position: 'absolute',
background: '#231F20',
}}
/>
<div
style={{
width: 3.14,
height: 1.57,
left: 46.28,
top: 12.04,
position: 'absolute',
outline: '1.46px #231F20 solid',
outlineOffset: '-0.73px',
}}
/>
<div
style={{
width: 5.44,
height: 7.12,
left: 45.11,
top: 19.99,
position: 'absolute',
background: '#231F20',
}}
/>
<div
style={{
width: 11.7,
height: 13.88,
left: 21.49,
top: 8.01,
position: 'absolute',
background: 'white',
outline: '0.68px #231F20 solid',
outlineOffset: '-0.34px',
}}
/>
</div>
</div>
</div>
</div>;

View File

@@ -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 (
<SpottableDiv
className={`${css.itemCard} ${isFocused ? css.focused : ''}`}
onClick={onClick}
spotlightId={spotlightId}
data-spotlight-default={dataSpotlightDefault}
onKeyDown={onKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseEnter={onMouseEnter}
>
<img src={prdtImgPath} alt={prdtId} className={css.itemImage} />
<div className={css.itemInfo}>
<div className={css.itemName}>{prdtNm}</div>
<div className={css.itemPrice}>
<span className={css.salePrice}>{salePrice}</span>
<span className={css.originalPrice}>{originalPrice}</span>
</div>
{energyLabels && energyLabels.length > 0 && (
<div className={css.energyLabels}>
{energyLabels.map((label, labelIndex) => (
<div key={labelIndex} className={css.energyLabel}>
{label}
</div>
))}
</div>
)}
</div>
</SpottableDiv>
);
}
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,
};

View File

@@ -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;
}
}
}