diff --git a/src/StrategyApiView.test.tsx b/src/StrategyApiView.test.tsx index 7b9fc75..34ca8e4 100644 --- a/src/StrategyApiView.test.tsx +++ b/src/StrategyApiView.test.tsx @@ -255,9 +255,9 @@ describe('Strategy API view', () => { expect(screen.queryByText('전략을 저장하지 못했습니다.')).not.toBeInTheDocument(); }); - /* The published catalog is several hundred instruments. A plain dropdown listed - every one of them at once, which is unreadable and unscannable. */ - test('keeps the instrument picker short and searchable for a large catalog', async () => { + /* The published catalog is several hundred instruments. Keep the picker viewport + compact and scrollable without truncating the selected alphabet group. */ + test('shows every matching instrument in a large catalog', async () => { const user = userEvent.setup(); const catalog: BasicStrategyCatalog = { version: { @@ -276,10 +276,17 @@ describe('Strategy API view', () => { await waitFor(() => expect(catalogClient.getBasic).toHaveBeenCalledWith(expect.any(AbortSignal))); await user.click(screen.getByRole('button', { name: 'PARTITION 01 종목 관리' })); - expect(within(screen.getByRole('listbox', { name: '추가할 종목' })).getAllByRole('option')).toHaveLength(50); - expect(screen.getByText(/270개는 표시하지 않았습니다/)).toBeInTheDocument(); + const listbox = screen.getByRole('listbox', { name: '추가할 종목' }); + expect(within(listbox).getAllByRole('option')).toHaveLength(320); + expect(screen.queryByText(/표시하지 않았습니다/)).not.toBeInTheDocument(); - // Searching is the way to the other 270, and Enter takes the top match. + await user.click(screen.getByRole('button', { name: 'S' })); + expect(within(listbox).getAllByRole('option')).toHaveLength(320); + + await user.click(screen.getByRole('button', { name: 'ALL' })); + expect(within(listbox).getAllByRole('option')).toHaveLength(320); + + // Searching still narrows the complete set, and Enter takes the top match. await user.type(screen.getByLabelText('종목 검색'), 'SYM31'); await waitFor(() => expect( within(screen.getByRole('listbox', { name: '추가할 종목' })).getAllByRole('option'), @@ -288,6 +295,45 @@ describe('Strategy API view', () => { expect(screen.getByRole('dialog')).toHaveTextContent('SYM310'); }); + test('filters the instrument picker by ticker initial before scrolling', async () => { + const user = userEvent.setup(); + const catalog: BasicStrategyCatalog = { + version: { + id: 'catalog-id', languageVersion: 'basic/v1', schemaVersion: 'schema/v1', catalogVersion: 'catalog/v1', + dataRequirementVersion: 'data/v1', definitionHash: 'catalog-hash', publishedAt: '2026-08-01T12:00:00Z', retiredAt: null, + }, + elements: [], + features: [], + instruments: [ + { id: 'aapl-id', assetType: 'STOCK', primaryExchangeMic: 'XNAS', currencyCode: 'USD', symbol: 'AAPL' }, + { id: 'amd-id', assetType: 'STOCK', primaryExchangeMic: 'XNAS', currencyCode: 'USD', symbol: 'AMD' }, + { id: 'meta-id', assetType: 'STOCK', primaryExchangeMic: 'XNAS', currencyCode: 'USD', symbol: 'META' }, + { id: 'msft-id', assetType: 'STOCK', primaryExchangeMic: 'XNAS', currencyCode: 'USD', symbol: 'MSFT' }, + { id: 'spy-id', assetType: 'ETF', primaryExchangeMic: 'ARCX', currencyCode: 'USD', symbol: 'SPY' }, + ], + }; + const catalogClient: StrategyCatalogClient = { getBasic: vi.fn().mockResolvedValue(catalog) }; + render( {}} catalogClient={catalogClient} />); + await waitFor(() => expect(catalogClient.getBasic).toHaveBeenCalledWith(expect.any(AbortSignal))); + await user.click(screen.getByRole('button', { name: 'PARTITION 01 종목 관리' })); + + const alphabetFilter = screen.getByRole('group', { name: '종목 알파벳 필터' }); + expect(within(alphabetFilter).getAllByRole('button')).toHaveLength(27); + + await user.click(within(alphabetFilter).getByRole('button', { name: 'A' })); + expect(within(alphabetFilter).getByRole('button', { name: 'A' })).toHaveAttribute('aria-pressed', 'true'); + expect(within(screen.getByRole('listbox', { name: '추가할 종목' })).getAllByRole('option').map((option) => option.textContent)).toEqual([ + 'AAPLSTOCK · XNAS', + 'AMDSTOCK · XNAS', + ]); + + await user.click(within(alphabetFilter).getByRole('button', { name: 'M' })); + expect(within(screen.getByRole('listbox', { name: '추가할 종목' })).getAllByRole('option').map((option) => option.textContent)).toEqual([ + 'METASTOCK · XNAS', + 'MSFTSTOCK · XNAS', + ]); + }); + test('retries a transient lease conflict left by a page navigation', async () => { const document: StrategyDocument = { strategyId: 'reloaded', diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index bb91738..c411258 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -1341,6 +1341,7 @@ const english: Record = { '연결 상태를 확인한 뒤 다시 시도해 주세요.': 'Check your connection and try again.', '연결 상태를 확인한 뒤 다시 시도해 주세요. 확인되지 않은 편집 내용은 표시하지 않습니다.': 'Check your connection and try again. Unverified editing content is not shown.', '추가할 종목': 'Symbol to add', + '종목 알파벳 필터': 'Symbol alphabet filter', '종목 미선택': 'No symbol selected', '실제 시장 데이터 기반 미리보기만 표시합니다.': 'Only previews backed by real market data are shown.', '현재 미리보기 데이터 계약이 연결되지 않아 차트와 거래 신호를 표시하지 않습니다. 저장된 종목과 전략 규칙은 편집 화면에서 계속 확인할 수 있습니다.': 'The preview data contract is not connected, so no chart or trade signals are shown. You can still review saved symbols and strategy rules in the editor.', diff --git a/src/styles/balanced.css b/src/styles/balanced.css index 51f229f..27e0856 100644 --- a/src/styles/balanced.css +++ b/src/styles/balanced.css @@ -6645,6 +6645,43 @@ } .symbol-manager-picker { display: grid; gap: 8px; } .symbol-manager-picker-actions { display: flex; justify-content: flex-end; gap: 8px; } +.symbol-manager-alphabet { + display: grid; + grid-template-columns: repeat(9, minmax(0, 1fr)); + gap: 4px; + padding: 6px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-2); +} +.symbol-manager-alphabet button { + min-width: 0; + height: 28px; + padding: 0; + border: 1px solid transparent; + border-radius: 7px; + color: var(--text-faint); + background: transparent; + font: 800 9px var(--font-mono); + cursor: pointer; +} +.symbol-manager-alphabet button:hover:not(:disabled) { + color: var(--text); + background: var(--surface-3); +} +.symbol-manager-alphabet button.is-active { + border-color: color-mix(in srgb, var(--accent) 55%, var(--line-strong)); + color: var(--accent); + background: var(--accent-soft); +} +.symbol-manager-alphabet button:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--accent-soft); +} +.symbol-manager-alphabet button:disabled { + opacity: .3; + cursor: default; +} .symbol-manager-results-head { display: flex; align-items: baseline; diff --git a/src/views/StrategyViews.tsx b/src/views/StrategyViews.tsx index 6020400..b32591e 100644 --- a/src/views/StrategyViews.tsx +++ b/src/views/StrategyViews.tsx @@ -718,11 +718,11 @@ const BLOCK_LIBRARY: BlockLibraryCategory[] = [ const BASIC_FAVORITE_BLOCKS_STORAGE_KEY = 'i2s-basic-editor-favorite-blocks-v1'; const LOCAL_PREVIEW_SYMBOLS = ['AAPL', 'MSFT', 'SPY', 'NVDA', 'QQQ']; +const INSTRUMENT_INITIALS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); /* The local preview list carries no exchange or asset type, so those stay optional and the picker simply omits the second line when the catalog is absent. */ type SelectableInstrument = Pick & Partial>; -const INSTRUMENT_RESULT_LIMIT = 50; const getLibraryBlockTone = (label: string): BlockTone => ( BLOCK_LIBRARY.find((category) => category.items.includes(label))?.tone ?? 'neutral' ); @@ -1666,6 +1666,7 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st const validationPreviewRevisionRef = useRef(0); const [pendingInstrumentKey, setPendingInstrumentKey] = useState(''); const [instrumentQuery, setInstrumentQuery] = useState(''); + const [instrumentInitial, setInstrumentInitial] = useState(null); // Two-phase dismissal so the toast can slide back down (mirroring its entry) // instead of vanishing instantly. const [saveFeedbackClosing, setSaveFeedbackClosing] = useState(false); @@ -3609,17 +3610,27 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st ? [] : LOCAL_PREVIEW_SYMBOLS.map((symbol) => ({ id: '', symbol })); const normalizedInstrumentQuery = instrumentQuery.trim().toLocaleUpperCase('en-US'); - const availableInstruments = selectableInstruments.filter((instrument) => !managedSymbols.includes(instrument.symbol) - && (!normalizedInstrumentQuery || instrument.symbol.toLocaleUpperCase('en-US').includes(normalizedInstrumentQuery))); - /* The published catalog runs to several hundred instruments. Rendering them all - turns the picker into an unreadable wall, so the list stays short and the - search box is the way through it. */ - const visibleInstruments = availableInstruments.slice(0, INSTRUMENT_RESULT_LIMIT); - const hiddenInstrumentCount = availableInstruments.length - visibleInstruments.length; + const unselectedInstruments = selectableInstruments.filter((instrument) => !managedSymbols.includes(instrument.symbol)); + const availableInstrumentInitials = new Set(unselectedInstruments.map((instrument) => ( + instrument.symbol.toLocaleUpperCase('en-US').charAt(0) + ))); + const availableInstruments = unselectedInstruments.filter((instrument) => { + const normalizedSymbol = instrument.symbol.toLocaleUpperCase('en-US'); + return (!instrumentInitial || normalizedSymbol.startsWith(instrumentInitial)) + && (!normalizedInstrumentQuery || normalizedSymbol.includes(normalizedInstrumentQuery)); + }); + /* The viewport stays compact and scrollable, while every match remains available + within the selected alphabet group or search result. */ + const visibleInstruments = availableInstruments; const selectedInstrument = availableInstruments.find((instrument) => (instrument.id || instrument.symbol) === pendingInstrumentKey) ?? visibleInstruments[0]; const instrumentKey = (instrument: SelectableInstrument) => instrument.id || instrument.symbol; const selectedInstrumentKey = selectedInstrument ? instrumentKey(selectedInstrument) : ''; + const selectInstrumentInitial = (initial: string | null) => { + setInstrumentInitial(initial); + setInstrumentQuery(''); + setPendingInstrumentKey(''); + }; const moveInstrumentSelection = (delta: number) => { if (visibleInstruments.length === 0) return; const current = visibleInstruments.findIndex((instrument) => instrumentKey(instrument) === selectedInstrumentKey); @@ -3849,7 +3860,7 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st
PARTITION {sectionNumber}{section.symbol}매수 {section.cards.buy.length} · 매도 {section.cards.sell.length}
- +
@@ -4003,7 +4014,7 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st aria-controls="symbol-manager-results" aria-activedescendant={selectedInstrument ? `symbol-option-${selectedInstrumentKey}` : undefined} value={instrumentQuery} - onChange={(event) => { setInstrumentQuery(event.target.value); setPendingInstrumentKey(''); }} + onChange={(event) => { setInstrumentQuery(event.target.value); setInstrumentInitial(null); setPendingInstrumentKey(''); }} onKeyDown={(event) => { if (event.key === 'ArrowDown') { event.preventDefault(); moveInstrumentSelection(1); } else if (event.key === 'ArrowUp') { event.preventDefault(); moveInstrumentSelection(-1); } @@ -4011,6 +4022,22 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st }} /> +
+ + {INSTRUMENT_INITIALS.map((initial) => )} +
추가할 종목 {availableInstruments.length}개 선택 가능 @@ -4038,9 +4065,6 @@ export function BasicEditor({ goBack, openEditor, onLaunchBot, blank = false, st ; })} } - {hiddenInstrumentCount > 0 && - {hiddenInstrumentCount}개는 표시하지 않았습니다. 티커를 입력해 좁혀 주세요. - } {!catalogError && basicCatalog && normalizedInstrumentQuery && availableInstruments.length === 0 && 일치하는 공식 지원 종목이 없습니다.} {!catalogError && basicCatalog && !normalizedInstrumentQuery && availableInstruments.length === 0 && 추가할 수 있는 종목을 모두 담았습니다.}