Skip to content

Commit a62a223

Browse files
Harden suggestion combobox: guard empty pick, fix dropdown paint and
scroll Guard the Enter handler against an absent pick when glossedRanked empties while suggestionsOpen is stale-true. Switch SuggestionDropdown positioning to useLayoutEffect to avoid a top-left flash, and scroll the active row into view during keyboard navigation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 75c3f58 commit a62a223

4 files changed

Lines changed: 30 additions & 8 deletions

File tree

src/__tests__/components/TokenChip.suggestions.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ beforeEach(() => {
2121
jest
2222
.mocked(useLocalizedStrings)
2323
.mockReturnValue([{ '%interlinearizer_glossInput_placeholder%': 'gloss' }, false]);
24+
// jsdom does not implement scrollIntoView; the dropdown calls it to keep the active row in view.
25+
Element.prototype.scrollIntoView = jest.fn();
2426
});
2527

2628
/**

src/components/SuggestionDropdown.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* the clipping and stacking of the interlinear view's scroll viewports and token-row stacking
66
* contexts.
77
*/
8-
import { useEffect, useRef, useState } from 'react';
8+
import { useLayoutEffect, useRef, useState } from 'react';
99
import { createPortal } from 'react-dom';
1010
import type { GlossedSuggestionEntry } from '../utils/suggestion-engine';
1111
import { statusTextColorClass } from '../utils/status-colors';
@@ -82,7 +82,9 @@ export default function SuggestionDropdown({
8282
// it moves, then close only once the anchor has scrolled out of the viewport (a far user scroll
8383
// that abandons this token). A capture listener catches scrolls of ancestor viewports; scrolls of
8484
// the panel's own overflow are ignored so a long list can be scrolled without moving or closing it.
85-
useEffect(() => {
85+
// Layout effect so the first measurement runs before paint — otherwise the portaled panel flashes
86+
// at the default top-left before snapping under the anchor.
87+
useLayoutEffect(() => {
8688
const anchor = anchorRef.current;
8789
/* v8 ignore next -- the chip only mounts this while the input (the anchor) is rendered */
8890
if (!anchor) return undefined;
@@ -109,6 +111,15 @@ export default function SuggestionDropdown({
109111
};
110112
}, [anchorRef, onRequestClose]);
111113

114+
// Keep the keyboard-highlighted row inside the panel's scroll window. Arrow navigation moves
115+
// activeIndex past the visible edge of the max-h-48 overflow without this; scrollIntoView with
116+
// block: 'nearest' only scrolls when the row is actually clipped, so it leaves an in-view row put.
117+
useLayoutEffect(() => {
118+
if (activeIndex < 0) return;
119+
const active = listRef.current?.querySelector(`#${CSS.escape(optionId(activeIndex))}`);
120+
active?.scrollIntoView({ block: 'nearest' });
121+
}, [activeIndex, optionId, entries]);
122+
112123
return createPortal(
113124
<ul
114125
ref={setListRef}

src/components/TokenChip.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,10 +275,17 @@ export function TokenChip({
275275
closeSuggestions();
276276
} else if (e.key === 'Enter') {
277277
e.preventDefault();
278-
// activeIndex -1 (nothing highlighted) falls back to the top row; the list is non-empty
279-
// whenever the dropdown is open, so a pick always exists.
278+
// activeIndex -1 (nothing highlighted) falls back to the top row. The list is normally
279+
// non-empty while open, but glossedRanked can empty out after open (a row approved away),
280+
// leaving suggestionsOpen stale while the dropdown is unmounted — guard so Enter closes
281+
// rather than dereferencing an absent pick.
282+
// activeIndex -1 (nothing highlighted) falls back to the top row. The list is normally
283+
// non-empty while open, but glossedRanked can empty out after open (a row approved away)
284+
// while suggestionsOpen is still true, so guard rather than dereference an absent pick.
280285
const pick = glossedRanked[activeIndex] ?? glossedRanked[0];
281-
selectSuggestion(pick.id);
286+
if (pick) selectSuggestion(pick.id);
287+
/* v8 ignore next -- defensive: the empty-pick race above is not reachable from the call sites */ else
288+
closeSuggestions();
282289
}
283290
} else if (e.key === 'ArrowDown' && hasSuggestions) {
284291
e.preventDefault();

src/store/analysisSlice.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,9 @@ const analysisSlice = createSlice({
632632
const lang = state.analysisLanguage;
633633

634634
const resolved = resolveApprovedAnalysis(state, tokenRef);
635-
const morpheme = resolved?.analysis.morphemes?.find((m) => m.id === morphemeId);
635+
if (!resolved) return;
636+
const { analysis } = resolved;
637+
const morpheme = analysis.morphemes?.find((m) => m.id === morphemeId);
636638
if (!morpheme) return;
637639

638640
if (value.trim() === '') {
@@ -646,7 +648,7 @@ const analysisSlice = createSlice({
646648
// (an edit re-merges, a clear back to the sibling's state leaves a duplicate the suggestion
647649
// pool would double-count), and unlike writeGloss this clear keeps the morpheme record, so
648650
// the duplicate payload survives rather than being reclaimed.
649-
mergeIntoIdenticalPayload(state, resolved.analysis);
651+
mergeIntoIdenticalPayload(state, analysis);
650652
return;
651653
}
652654

@@ -656,7 +658,7 @@ const analysisSlice = createSlice({
656658
// in place can make this payload identical to an existing one (e.g. a homograph whose only
657659
// difference was this morpheme's gloss); re-converge so the dedupe the create path guarantees
658660
// on first write also holds after morpheme gloss edits (mirrors writeGloss/writeMorphemes).
659-
mergeIntoIdenticalPayload(state, resolved.analysis);
661+
mergeIntoIdenticalPayload(state, analysis);
660662
},
661663
forkAnalysisForToken: {
662664
/**

0 commit comments

Comments
 (0)