diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index afc9c3f..9cf6c36 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -426,7 +426,7 @@ public class StrategyDslToTa4jAdapter { if (needsCrossTimeframeComposite(cond, ctx)) { Rule composite = buildCrossTimeframeCompositeRule(cond, ctx); - return applyCandleRangeWindow(composite, candleRangeMode, candleRange); + return applyCandleRangeWindow(composite, candleRangeMode, candleRange, condType); } PriceExtremeNorm px = normalizePriceExtremeCondition(cond); @@ -466,8 +466,8 @@ public class StrategyDslToTa4jAdapter { // CrossedUp/Down: ta4j 0.22 에서는 2인자 생성자만 있음 case "CROSS_UP" -> new CrossedUpIndicatorRule(left, right); case "CROSS_DOWN" -> new CrossedDownIndicatorRule(left, right); - case "SLOPE_UP" -> new IsRisingRule(left, slopePeriod); - case "SLOPE_DOWN" -> new IsFallingRule(left, slopePeriod); + case "SLOPE_UP" -> buildSlopeTrendRule(left, true, candleRangeMode, candleRange, slopePeriod); + case "SLOPE_DOWN" -> buildSlopeTrendRule(left, false, candleRangeMode, candleRange, slopePeriod); case "DIFF_GT" -> { double v = cond.path("compareValue").asDouble(0); yield new OverIndicatorRule( @@ -508,7 +508,7 @@ public class StrategyDslToTa4jAdapter { yield new BooleanRule(false); } }; - Rule ranged = applyCandleRangeWindow(core, candleRangeMode, candleRange); + Rule ranged = applyCandleRangeWindow(core, candleRangeMode, candleRange, condType); if (px != null) { return nanSafeCompareRule(ranged, left, right); } @@ -789,6 +789,18 @@ public class StrategyDslToTa4jAdapter { if (field.equals("CCI_VALUE") || indType.equals("CCI")) return new CciOnSourceIndicator(resolvePriceSource(s, CciOnSourceIndicator.normalizeParams(p)), periodOverride > 0 ? periodOverride : intP(p, "length", 13)); + if (field.equals("CCI_SIGNAL")) { + Map norm = CciOnSourceIndicator.normalizeParams(p); + CciOnSourceIndicator cci = new CciOnSourceIndicator(resolvePriceSource(s, norm), intP(p, "length", 13)); + String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA"; + int maLen = intP(p, "maLength", 10); + if ("None".equals(maType)) return cci; + return switch (maType) { + case "EMA" -> new EMAIndicator(cci, maLen); + case "WMA" -> new WMAIndicator(cci, maLen); + default -> new SMAIndicator(cci, maLen); + }; + } // RSI — 기간 접미사(RSI_VALUE_9) 또는 기본 RSI_VALUE if (field.startsWith("RSI_VALUE_")) { int period = parseTrailingInt(field, "RSI_VALUE_", intP(p, "length", 14)); @@ -796,6 +808,17 @@ public class StrategyDslToTa4jAdapter { } if (field.equals("RSI_VALUE")) return new RSIIndicator(close, periodOverride > 0 ? periodOverride : intP(p, "length", 14)); + if (field.equals("RSI_SIGNAL")) { + RSIIndicator rsi = new RSIIndicator(close, intP(p, "length", 14)); + String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA"; + int maLen = intP(p, "maLength", 14); + if ("None".equals(maType)) return rsi; + return switch (maType) { + case "EMA" -> new EMAIndicator(rsi, maLen); + case "WMA" -> new WMAIndicator(rsi, maLen); + default -> new SMAIndicator(rsi, maLen); + }; + } // MACD if (field.equals("MACD_LINE")) { return new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26)); @@ -1062,8 +1085,16 @@ public class StrategyDslToTa4jAdapter { if (cond == null || cond.isNull()) return ""; int candleRange = cond.path("candleRange").asInt(1); String mode = cond.path("candleRangeMode").asText(""); + String condType = cond.path("conditionType").asText(""); if (mode.isBlank()) return ""; int n = Math.max(2, candleRange); + if (isSlopeConditionType(condType) && ("EXISTS_IN".equals(mode) || "NOT_EXISTS_IN".equals(mode))) { + return switch (mode) { + case "EXISTS_IN" -> "최근 " + n + "봉 구간 "; + case "NOT_EXISTS_IN" -> "최근 " + n + "봉 구간 비추세 · "; + default -> ""; + }; + } return switch (mode) { case "EXISTS_IN" -> "최근 " + n + "봉 내 "; case "NOT_EXISTS_IN" -> "최근 " + n + "봉 내 없음 · "; @@ -1079,8 +1110,10 @@ public class StrategyDslToTa4jAdapter { } /** 최근 N봉(현재 봉 포함) 윈도우에 inner 규칙 적용 */ - private Rule applyCandleRangeWindow(Rule core, String mode, int candleRange) { + private Rule applyCandleRangeWindow(Rule core, String mode, int candleRange, String condType) { if ("CURRENT".equals(mode)) return core; + // 기울기 + N봉 모드: buildSlopeTrendRule 에서 구간 추세로 이미 평가 + if (isSlopeConditionType(condType)) return core; int n = Math.max(2, candleRange); return switch (mode) { case "EXISTS_IN" -> buildExistsInWindowRule(core, n); @@ -1089,6 +1122,35 @@ public class StrategyDslToTa4jAdapter { }; } + /** + * 기울기 조건. + * CURRENT: slopePeriod 구간 추세(현재 봉 기준). + * EXISTS_IN / NOT_EXISTS_IN: candleRange(N) 구간 전체 그래프가 상승/하락 상태인지 현재 봉에서 판정 + * (N봉 내 임의 시점의 짧은 기울기 존재 여부가 아님). + */ + private Rule buildSlopeTrendRule( + Indicator left, + boolean up, + String candleRangeMode, + int candleRange, + int slopePeriod) { + int bars = slopePeriod; + if ("EXISTS_IN".equals(candleRangeMode) || "NOT_EXISTS_IN".equals(candleRangeMode)) { + bars = Math.max(2, candleRange); + } else { + bars = Math.max(1, slopePeriod); + } + Rule trend = up ? new IsRisingRule(left, bars) : new IsFallingRule(left, bars); + if ("NOT_EXISTS_IN".equals(candleRangeMode)) { + return new NotRule(trend); + } + return trend; + } + + private static boolean isSlopeConditionType(String condType) { + return "SLOPE_UP".equals(condType) || "SLOPE_DOWN".equals(condType); + } + /** 최근 N봉 중 어느 한 봉이라도 inner 가 true */ private Rule buildExistsInWindowRule(Rule inner, int n) { if (n <= 1) return inner; diff --git a/backend/src/test/java/com/goldenchart/service/SlopeCandleRangeRuleTest.java b/backend/src/test/java/com/goldenchart/service/SlopeCandleRangeRuleTest.java new file mode 100644 index 0000000..6060b5a --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/SlopeCandleRangeRuleTest.java @@ -0,0 +1,149 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeriesBuilder; +import org.ta4j.core.Rule; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.num.DoubleNumFactory; + +import java.time.Duration; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 기울기 + N봉 내 존재 — N봉 구간 전체 추세 판정 (임의 시점 짧은 기울기 존재 여부 아님). + */ +class SlopeCandleRangeRuleTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private StrategyDslToTa4jAdapter adapter; + private BarSeries series; + + @BeforeEach + void setUp() { + adapter = new StrategyDslToTa4jAdapter(); + // 중간 3봉만 상승, 전체 10봉은 횡보·하락 혼재 → 짧은 기울기만 존재 + series = buildOscillatingSeries(new double[] { + 100, 101, 100, 101, 100, 101, 102, 103, 102, 101 + }); + } + + @Test + void slopeUp_existsIn_usesFullWindowTrend_notAnyShortRiseInWindow() throws Exception { + Rule rule = toSlopeRule(""" + "candleRangeMode": "EXISTS_IN", + "candleRange": 10, + "slopePeriod": 3 + """, "SLOPE_UP"); + int idx = series.getEndIndex(); + assertFalse(rule.isSatisfied(idx, null), + "10봉 구간 전체가 상승 추세가 아니면 false (중간 3봉 상승만으로는 true 아님)"); + } + + @Test + void slopeUp_current_usesSlopePeriod() throws Exception { + BarSeries rising = buildOscillatingSeries(new double[] { + 100, 101, 102, 103, 104 + }); + Rule rule = toSlopeRule(""" + "candleRangeMode": "CURRENT", + "slopePeriod": 3 + """, "SLOPE_UP", rising); + assertTrue(rule.isSatisfied(rising.getEndIndex(), null), + "현재 봉 기준 slopePeriod 구간 상승이면 true"); + } + + @Test + void slopeUp_existsIn_monotonicWindow_true() throws Exception { + BarSeries rising = buildOscillatingSeries(new double[] { + 100, 101, 102, 103, 104, 105, 106, 107, 108, 109 + }); + Rule rule = toSlopeRule(""" + "candleRangeMode": "EXISTS_IN", + "candleRange": 10, + "slopePeriod": 3 + """, "SLOPE_UP", rising); + assertTrue(rule.isSatisfied(rising.getEndIndex(), null), + "10봉 구간 전체 상승 추세이면 true"); + } + + @Test + void slopeDown_notExistsIn_trueWhenWindowNotFalling() throws Exception { + Rule rule = toSlopeRule(""" + "candleRangeMode": "NOT_EXISTS_IN", + "candleRange": 10, + "slopePeriod": 3 + """, "SLOPE_DOWN"); + assertTrue(rule.isSatisfied(series.getEndIndex(), null), + "10봉 구간이 하락 추세가 아니면 NOT_EXISTS_IN(SLOPE_DOWN) true"); + } + + @Test + void candleRangePrefix_slopeExistsIn_usesPeriodWording() throws Exception { + JsonNode cond = MAPPER.readTree(""" + { + "conditionType": "SLOPE_UP", + "candleRangeMode": "EXISTS_IN", + "candleRange": 8 + } + """); + assertEquals("최근 8봉 구간 ", StrategyDslToTa4jAdapter.candleRangeConditionPrefix(cond)); + } + + private Rule toSlopeRule(String extraFields, String condType) throws Exception { + return toSlopeRule(extraFields, condType, series); + } + + private Rule toSlopeRule(String extraFields, String condType, BarSeries s) throws Exception { + String extra = extraFields == null || extraFields.isBlank() + ? "" + : ",\n " + extraFields.strip().replaceAll(",\\s*$", ""); + String json = """ + { + "id": "c-slope", + "type": "CONDITION", + "condition": { + "indicatorType": "MA", + "conditionType": "%s", + "leftField": "CLOSE_PRICE", + "rightField": "NONE"%s + } + } + """.formatted(condType, extra); + JsonNode node = MAPPER.readTree(json); + Rule rule = adapter.toRule(node, s, java.util.Map.of()); + assertNotNull(rule); + return rule; + } + + private static BarSeries buildOscillatingSeries(double[] closes) { + BarSeries s = new BaseBarSeriesBuilder() + .withName("test-slope") + .withNumFactory(DoubleNumFactory.getInstance()) + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .build(); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + Instant base = Instant.parse("2024-06-01T00:00:00Z"); + Duration period = Duration.ofMinutes(1); + for (int i = 0; i < closes.length; i++) { + double close = closes[i]; + Instant end = base.plus(period.multipliedBy(i + 1L)); + factory.createBarBuilder(s) + .timePeriod(period) + .endTime(end) + .openPrice(close - 1) + .highPrice(close + 1) + .lowPrice(close - 1) + .closePrice(close) + .volume(10 + i) + .add(); + } + return s; + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css index bd6d151..c727714 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -253,6 +253,15 @@ html.desktop-client .tmb-logo-version { position: relative; flex-shrink: 0; } +/* 버튼 ↔ 드롭다운 간격(6px)에서 hover 끊김 방지 */ +.tmb-nav-group--open::after { + content: ''; + position: absolute; + top: 100%; + left: 0; + width: max(100%, 168px); + height: 10px; +} .tmb-nav-group-btn { padding-right: 8px; } diff --git a/frontend/src/components/StrategyPage.tsx b/frontend/src/components/StrategyPage.tsx index 75049e4..82e5f2f 100644 --- a/frontend/src/components/StrategyPage.tsx +++ b/frontend/src/components/StrategyPage.tsx @@ -12,9 +12,7 @@ import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto, } from '../utils/backendApi'; -import { getIndicatorDef, getHLineLabel } from '../utils/indicatorRegistry'; -import { - getStrategyTemplates, +import { getStrategyTemplates, simpleTemplateToNode, type StrategyTemplateDef, } from '../utils/strategyPresets'; @@ -29,6 +27,11 @@ import { buildEmaFieldOptions, buildMaFieldOptions, } from '../utils/maDslField'; +import { + buildDef, + DEF_DEFAULTS, + type DefType, +} from '../utils/strategyEditorShared'; // ─── 타입 ───────────────────────────────────────────────────────────────────── type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME'; @@ -81,185 +84,10 @@ const genId = () => `n_${++_cnt}_${Date.now()}`; const loadStratsLocal = (): StrategyDto[] => []; const saveStratsLocal = (_s: StrategyDto[]) => { /* DB only */ }; -// ─── 기본 파라미터 (IndicatorSettings 미사용 → 하드코딩 기본값) ───────────── +// ─── 기본 파라미터 — strategyEditorShared.buildDef 사용 ───────────────────── /** 지표별 hline 임계값 (과열선/중앙선/침체선) */ interface HLThresh { over?: number; mid?: number; under?: number; } -/** 하드코딩 기본값 — activeIndicators가 없을 때 폴백 */ -const DEF_DEFAULTS = { - rsiPeriod: 9, - macdFast: 12, macdSlow: 26, macdSignal: 9, - cciPeriod: 13, - stochK: 12, stochD: 5, - adxPeriod: 14, - trixPeriod: 12, trixSignal: 9, - dmiPeriod: 14, - obvPeriod: 9, obvSignal: 9, - bbPeriod: 20, bbStdDev: 2, - ichTenkan: 9, ichKijun: 26, ichSenkouB: 52, - newPsy: 10, psyPeriod: 12, investPsy: 10, - williamsR: 14, - bwiPeriod: 20, - vrPeriod: 10, - volOscShort: 5, volOscLong: 10, - dispUltra: 5, dispShort: 10, dispMid: 20, dispLong: 60, - maLines: [5, 10, 20, 60, 120] as number[], - /** 지표별 hline 임계값 (과열선/중앙선/침체선) */ - hlThresh: { - RSI: { over: 70, mid: 50, under: 30 }, - STOCHASTIC: { over: 80, mid: 50, under: 20 }, - CCI: { over: 100, mid: 0, under: -100 }, - WILLIAMS_R: { over: -20, mid: -50, under: -80 }, - BWI: { over: 80, mid: 50, under: 20 }, - VR: { over: 200, mid: 100, under: 50 }, - PSYCHOLOGICAL: { over: 75, mid: 50, under: 25 }, - NEW_PSYCHOLOGICAL: { over: 50, mid: 0, under: -50 }, - INVEST_PSYCHOLOGICAL: { over: 75, mid: 50, under: 25 }, - MACD: { mid: 0 }, - ADX: { over: 40, mid: 25, under: 20 }, - DMI: { over: 30, mid: 20, under: 10 }, - TRIX: { mid: 0 }, - VOLUME_OSC: { mid: 0 }, - } as Record, -}; -type DefType = typeof DEF_DEFAULTS; - -/** - * activeIndicators에서 파라미터를 추출해 DEF를 구성. - * 레지스트리 type명 / params 키는 indicatorRegistry.ts 의 defaultParams 기준. - */ -function buildDef(activeIndicators: IndicatorConfig[]): DefType { - // 레지스트리 type명으로 조회 - const p = (registryType: string) => - activeIndicators.find(i => i.type === registryType)?.params ?? {}; - const num = (params: Record, key: string, fallback: number) => - typeof params[key] === 'number' ? (params[key] as number) : fallback; - - // ── 각 지표별 실제 레지스트리 type명 + params 키 ────────────────────────── - // RSI → type:'RSI', params.length - const rsi = p('RSI'); - // MACD → type:'MACD', params.fastLength / slowLength / signalLength - const macd = p('MACD'); - // CCI → type:'CCI', params.length - const cci = p('CCI'); - // Stochastic → type:'Stochastic', params.kLength / dSmoothing - const stoch = p('Stochastic'); - // ADX → type:'ADX', params.adxSmoothing / diLength - const adx = p('ADX'); - // TRIX → type:'TRIX', params.length / signalLength - const trix = p('TRIX'); - // DMI → type:'DMI', params.diLength / adxSmoothing - const dmi = p('DMI'); - // BollingerBands→ type:'BollingerBands', params.length / mult - const bb = p('BollingerBands'); - // IchimokuCloud → type:'IchimokuCloud', params.conversionPeriods / basePeriods / laggingSpan2Periods - const ich = p('IchimokuCloud'); - // Williams %R → type:'WilliamsPercentRange', params.length - const wr = p('WilliamsPercentRange'); - const vo = p('VolumeOscillator'); - const vrInd = p('VR'); - const disp = p('Disparity'); - const psy = p('Psychological'); - const nPsy = p('NewPsychological'); - const iPsy = p('InvestPsychological'); - const obvP = p('OBV'); - // SMA (MA lines)→ type:'SMA', params keys depend on smaConfig - const sma = p('SMA'); - - // MA 기간 배열: SMA 설정에서 period1~period11 추출 (smaConfig 구조) - let maLines = DEF_DEFAULTS.maLines; - const smaPeriods = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] - .map(i => sma[`period${i}`]) - .filter(v => typeof v === 'number' && (v as number) > 0) as number[]; - if (smaPeriods.length >= 2) maLines = smaPeriods; - - // ── hline 임계값 추출 ─────────────────────────────────────────────────────── - // DSL 지표타입 → 레지스트리 type명 매핑 - const DSL_TO_REGISTRY: Record = { - RSI: 'RSI', - STOCHASTIC: 'Stochastic', - CCI: 'CCI', - WILLIAMS_R: 'WilliamsPercentRange', - MACD: 'MACD', - DMI: 'DMI', - ADX: 'ADX', - TRIX: 'TRIX', - VOLUME_OSC: 'VolumeOscillator', - VR: 'VR', - DISPARITY: 'Disparity', - PSYCHOLOGICAL: 'Psychological', - NEW_PSYCHOLOGICAL: 'NewPsychological', - INVEST_PSYCHOLOGICAL: 'InvestPsychological', - OBV: 'OBV', - BOLLINGER: 'BollingerBands', - }; - - const extractThresh = (dslType: string): HLThresh => { - const regType = DSL_TO_REGISTRY[dslType]; - if (!regType) return DEF_DEFAULTS.hlThresh[dslType] ?? {}; - const config = activeIndicators.find(i => i.type === regType); - const defHl = getIndicatorDef(regType)?.hlines ?? []; - const hlines = config?.hlines ?? defHl; - if (hlines.length === 0) return DEF_DEFAULTS.hlThresh[dslType] ?? {}; - - const prices = hlines.map(h => h.price); - const find = (lbl: string) => hlines.find(h => - (h.label ?? getHLineLabel(h.price, prices)) === lbl - ); - const result: HLThresh = {}; - const ov = find('과열선'); if (ov) result.over = ov.price; - const md = find('중앙선') ?? find('기준선'); if (md) result.mid = md.price; - const un = find('침체선'); if (un) result.under = un.price; - // 값이 하나도 없으면 기본값 사용 - if (result.over === undefined && result.mid === undefined && result.under === undefined) - return DEF_DEFAULTS.hlThresh[dslType] ?? {}; - return { - ...DEF_DEFAULTS.hlThresh[dslType], - ...result, - }; - }; - - const hlThresh: Record = {}; - for (const dslType of Object.keys(DEF_DEFAULTS.hlThresh)) { - hlThresh[dslType] = extractThresh(dslType); - } - - return { - rsiPeriod: num(rsi, 'length', DEF_DEFAULTS.rsiPeriod), - macdFast: num(macd, 'fastLength', DEF_DEFAULTS.macdFast), - macdSlow: num(macd, 'slowLength', DEF_DEFAULTS.macdSlow), - macdSignal: num(macd, 'signalLength', DEF_DEFAULTS.macdSignal), - cciPeriod: num(cci, 'length', DEF_DEFAULTS.cciPeriod), - stochK: num(stoch, 'kLength', DEF_DEFAULTS.stochK), - stochD: num(stoch, 'dSmoothing', DEF_DEFAULTS.stochD), - adxPeriod: num(adx, 'adxSmoothing', DEF_DEFAULTS.adxPeriod), - trixPeriod: num(trix, 'length', DEF_DEFAULTS.trixPeriod), - trixSignal: num(trix, 'signalLength', DEF_DEFAULTS.trixSignal), - dmiPeriod: num(dmi, 'diLength', num(dmi, 'length', DEF_DEFAULTS.dmiPeriod)), - obvPeriod: num(obvP, 'maLength', DEF_DEFAULTS.obvPeriod), - obvSignal: num(obvP, 'maLength', DEF_DEFAULTS.obvSignal), - bbPeriod: num(bb, 'length', DEF_DEFAULTS.bbPeriod), - bbStdDev: num(bb, 'mult', DEF_DEFAULTS.bbStdDev), - ichTenkan: num(ich, 'conversionPeriods', DEF_DEFAULTS.ichTenkan), - ichKijun: num(ich, 'basePeriods', DEF_DEFAULTS.ichKijun), - ichSenkouB: num(ich, 'laggingSpan2Periods', DEF_DEFAULTS.ichSenkouB), - psyPeriod: num(psy, 'length', DEF_DEFAULTS.psyPeriod), - newPsy: num(nPsy, 'length', DEF_DEFAULTS.newPsy), - investPsy: num(iPsy, 'length', DEF_DEFAULTS.investPsy), - williamsR: num(wr, 'length', DEF_DEFAULTS.williamsR), - bwiPeriod: DEF_DEFAULTS.bwiPeriod, // BWI는 레지스트리 미등록 - vrPeriod: num(vrInd, 'length', DEF_DEFAULTS.vrPeriod), - volOscShort: num(vo, 'shortLength', DEF_DEFAULTS.volOscShort), - volOscLong: num(vo, 'longLength', DEF_DEFAULTS.volOscLong), - dispUltra: num(disp, 'ultraLength', DEF_DEFAULTS.dispUltra), - dispShort: num(disp, 'shortLength', DEF_DEFAULTS.dispShort), - dispMid: num(disp, 'midLength', DEF_DEFAULTS.dispMid), - dispLong: num(disp, 'longLength', DEF_DEFAULTS.dispLong), - maLines, - hlThresh, - }; -} - /** * hline 임계값을 K_ 접두어 DSL 필드값으로 변환. * 예: 70 → "K_70", -100 → "K_-100" diff --git a/frontend/src/components/TopMenuBarNavGroups.tsx b/frontend/src/components/TopMenuBarNavGroups.tsx index cedc92d..354f2db 100644 --- a/frontend/src/components/TopMenuBarNavGroups.tsx +++ b/frontend/src/components/TopMenuBarNavGroups.tsx @@ -36,6 +36,11 @@ export const TopMenuBarNavGroups = memo(function TopMenuBarNavGroups({ }: Props) { const [openGroupId, setOpenGroupId] = useState(null); const navRef = useRef(null); + const closeTimerRef = useRef | null>(null); + const hoverCapableRef = useRef( + typeof window !== 'undefined' + && window.matchMedia('(hover: hover) and (pointer: fine)').matches, + ); const itemByPage = useMemo(() => { const map = new Map(); @@ -54,6 +59,28 @@ export const TopMenuBarNavGroups = memo(function TopMenuBarNavGroups({ const closeDropdown = useCallback(() => setOpenGroupId(null), []); + const cancelScheduledClose = useCallback(() => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + + const openGroup = useCallback((groupId: string) => { + cancelScheduledClose(); + setOpenGroupId(groupId); + }, [cancelScheduledClose]); + + const scheduleClose = useCallback(() => { + cancelScheduledClose(); + closeTimerRef.current = setTimeout(() => { + closeTimerRef.current = null; + setOpenGroupId(null); + }, 120); + }, [cancelScheduledClose]); + + useEffect(() => () => cancelScheduledClose(), [cancelScheduledClose]); + useEffect(() => { if (!openGroupId) return; const onDoc = (e: MouseEvent) => { @@ -96,13 +123,22 @@ export const TopMenuBarNavGroups = memo(function TopMenuBarNavGroups({ return ( {index > 0 &&