Skip to content

Commit 7d2245f

Browse files
Open suggestion dropdown on ArrowDown in gloss input
Pressing Down in a focused gloss input now opens the suggestion dropdown when the token has suggestions but it isn't already open — the keyboard equivalent of the chevron. Covers approved tokens, whose dropdown doesn't auto-open on focus but may hold pool alternatives. Also show the top suggestion as green ghost-text placeholder in empty inputs, so a glance across the row reveals which tokens have a suggestion without focusing or hovering.
1 parent f0be20e commit 7d2245f

9 files changed

Lines changed: 1129 additions & 410 deletions

File tree

__mocks__/lucide-react.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,13 @@ export function Link2Off(props: Readonly<{ size?: number; className?: string }>)
7474
export function Settings(props: Readonly<{ size?: number; className?: string }>): ReactElement {
7575
return <svg data-testid="settings-icon" {...props} />;
7676
}
77+
78+
/**
79+
* Stub for the ChevronDown icon used by the token chip's suggestion-dropdown toggle.
80+
*
81+
* @param props - SVG props forwarded from the component.
82+
* @returns A ReactElement SVG element used as a chevron-down icon stub in tests.
83+
*/
84+
export function ChevronDown(props: Readonly<{ size?: number; className?: string }>): ReactElement {
85+
return <svg data-testid="chevron-down-icon" {...props} />;
86+
}

package-lock.json

Lines changed: 213 additions & 310 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 434 additions & 19 deletions
Large diffs are not rendered by default.

src/__tests__/store/analysisSlice.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1684,9 +1684,12 @@ describe('selectResolvedTokenAnalysis', () => {
16841684
analysis: { analysis: makeAnalysis(ta), analysisLanguage: 'en' },
16851685
});
16861686

1687+
// The approved decision is canonical; the pool match for the same surface form rides along as
1688+
// `poolSuggestion` so the chevron can still offer re-promotion to a pool alternative.
16871689
expect(selectResolvedTokenAnalysis(store.getState().analysis, 'tok-1', 'word')).toEqual({
16881690
status: 'approved',
16891691
analysis: ta,
1692+
poolSuggestion: { suggested: ta, candidates: [] },
16901693
});
16911694
});
16921695

@@ -1729,10 +1732,12 @@ describe('approveAnalysisForToken', () => {
17291732
// No new payload — the accepting token links to the existing shared one.
17301733
expect(store.getState().analysis.analysis.tokenAnalyses).toHaveLength(1);
17311734
expect(selectApprovedLinkCountForPayload(store.getState().analysis, 'tok-2')).toBe(2);
1732-
// The suggestion is gone because the token now has its own approved decision.
1735+
// The token now resolves to its own approved decision rather than a `suggested` status; the pool
1736+
// match still rides along as `poolSuggestion` for the re-promotion chevron.
17331737
expect(selectResolvedTokenAnalysis(store.getState().analysis, 'tok-2', 'logos')).toEqual({
17341738
status: 'approved',
17351739
analysis: ta,
1740+
poolSuggestion: { suggested: ta, candidates: [] },
17361741
});
17371742
});
17381743

src/__tests__/utils/suggestion-engine.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,49 @@ describe('resolvedTokenAnalysisEqual', () => {
180180
).toBe(false);
181181
});
182182

183+
it('treats approvals with matching pool suggestions as equal', () => {
184+
expect(
185+
resolvedTokenAnalysisEqual(
186+
{
187+
status: 'approved',
188+
analysis: dog,
189+
poolSuggestion: { suggested: dog, candidates: [cat] },
190+
},
191+
{
192+
status: 'approved',
193+
analysis: dog,
194+
poolSuggestion: { suggested: dog, candidates: [cat] },
195+
},
196+
),
197+
).toBe(true);
198+
});
199+
200+
it('treats approvals differing only in pool-suggestion presence as unequal', () => {
201+
expect(
202+
resolvedTokenAnalysisEqual(
203+
{ status: 'approved', analysis: dog, poolSuggestion: { suggested: dog, candidates: [] } },
204+
{ status: 'approved', analysis: dog },
205+
),
206+
).toBe(false);
207+
});
208+
209+
it('treats approvals differing in a pool-suggestion candidate as unequal', () => {
210+
expect(
211+
resolvedTokenAnalysisEqual(
212+
{
213+
status: 'approved',
214+
analysis: dog,
215+
poolSuggestion: { suggested: dog, candidates: [cat] },
216+
},
217+
{
218+
status: 'approved',
219+
analysis: dog,
220+
poolSuggestion: { suggested: dog, candidates: [fish] },
221+
},
222+
),
223+
).toBe(false);
224+
});
225+
183226
it('treats suggestions with the same pick and candidate list as equal', () => {
184227
expect(
185228
resolvedTokenAnalysisEqual(
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @file The pop-down listbox a {@link TokenChip} shows while its gloss input is the active combobox.
3+
* Rendering and positioning live here; the combobox state (open, active row, keyboard) is owned
4+
* by the chip, which drives this purely through props. Portaled to `document.body` so it escapes
5+
* the clipping and stacking of the interlinear view's scroll viewports and token-row stacking
6+
* contexts.
7+
*/
8+
import { useEffect, useRef, useState } from 'react';
9+
import { createPortal } from 'react-dom';
10+
import type { GlossedSuggestionEntry } from '../utils/suggestion-engine';
11+
import { statusTextColorClass } from '../utils/status-colors';
12+
13+
/** Props for {@link SuggestionDropdown}. */
14+
type SuggestionDropdownProps = Readonly<{
15+
/**
16+
* The gloss input this dropdown anchors under. Read on open (and window resize) to position the
17+
* panel; never focused or mutated here so DOM focus stays in the input for the combobox.
18+
*/
19+
anchorRef: Readonly<{ current: HTMLInputElement | undefined }>;
20+
/** The listbox element id, matching the input's `aria-controls`. */
21+
listboxId: string;
22+
/**
23+
* Maps a row index to its option element id, matching the input's `aria-activedescendant`. Owned
24+
* by the chip so the input and the options agree on ids.
25+
*/
26+
optionId: (index: number) => string;
27+
/** The glossed suggestion entries to render, in rank order (the suggested pick first). */
28+
entries: readonly GlossedSuggestionEntry[];
29+
/** The keyboard-highlighted row, or -1 when none is highlighted (Enter then picks the top row). */
30+
activeIndex: number;
31+
/** The token's surface form, used only to build per-row accessible labels. */
32+
surfaceText: string;
33+
/** Called with a row index when the pointer enters it, so hover and keyboard share one highlight. */
34+
onActiveIndexChange: (index: number) => void;
35+
/** Called with a payload id when a row is chosen (approve the suggested / promote a candidate). */
36+
onSelect: (id: string) => void;
37+
/** Called when the dropdown should close itself (the user scrolled the view away). */
38+
onRequestClose: () => void;
39+
}>;
40+
41+
/**
42+
* Renders the portaled suggestion listbox for a token's gloss combobox. Row 0 is the suggested pick
43+
* (green); the rest are candidates (blue), matching the inline coloring this replaced. The
44+
* keyboard-active row gets the same `bg-accent` background hovering applies, and hovering a row
45+
* sets the active index so only one row is ever highlighted. Each row suppresses its mouse-down
46+
* default so clicking it never blurs the input — focus stays in the input and the click selects
47+
* instead. The panel closes itself on outer scrolling (the anchor would drift); scrolling the
48+
* panel's own overflow is ignored so a long list can be scrolled without dismissing it.
49+
*
50+
* @param props - Component props (see {@link SuggestionDropdownProps}).
51+
* @returns A `document.body` portal containing the listbox, positioned under the anchor input.
52+
*/
53+
export default function SuggestionDropdown({
54+
anchorRef,
55+
listboxId,
56+
optionId,
57+
entries,
58+
activeIndex,
59+
surfaceText,
60+
onActiveIndexChange,
61+
onSelect,
62+
onRequestClose,
63+
}: SuggestionDropdownProps) {
64+
const listRef = useRef<HTMLUListElement | undefined>(undefined);
65+
const [position, setPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
66+
67+
/**
68+
* Ref callback that stores the list element, used to tell apart scrolling the panel's own
69+
* overflow (ignored) from outer scrolling (closes). Normalizes React's `null` on unmount to
70+
* `undefined`.
71+
*
72+
* @param el - The mounted list, or `null` on unmount.
73+
*/
74+
const setListRef = (el: HTMLUListElement | null) => {
75+
listRef.current = el ?? undefined;
76+
};
77+
78+
// Position the panel under the anchor on open and keep it glued there across window resizes and
79+
// outer scrolling. The continuous view smooth-scrolls the token strip to center a phrase whenever
80+
// a token's gloss input is focused — the same focus that opens this dropdown — so closing on outer
81+
// scroll would dismiss the panel the instant it appeared. Instead we reposition under the anchor as
82+
// it moves, then close only once the anchor has scrolled out of the viewport (a far user scroll
83+
// that abandons this token). A capture listener catches scrolls of ancestor viewports; scrolls of
84+
// the panel's own overflow are ignored so a long list can be scrolled without moving or closing it.
85+
useEffect(() => {
86+
const anchor = anchorRef.current;
87+
/* v8 ignore next -- the chip only mounts this while the input (the anchor) is rendered */
88+
if (!anchor) return undefined;
89+
const updatePosition = () => {
90+
const rect = anchor.getBoundingClientRect();
91+
setPosition({ top: rect.bottom + 2, left: rect.left });
92+
};
93+
updatePosition();
94+
const handleScroll = (e: Event) => {
95+
if (e.target instanceof Node && listRef.current?.contains(e.target)) return;
96+
const rect = anchor.getBoundingClientRect();
97+
// The anchor has scrolled out of view: there is nothing to glue to, so dismiss the panel.
98+
if (rect.bottom < 0 || rect.top > window.innerHeight) {
99+
onRequestClose();
100+
return;
101+
}
102+
setPosition({ top: rect.bottom + 2, left: rect.left });
103+
};
104+
window.addEventListener('resize', updatePosition);
105+
window.addEventListener('scroll', handleScroll, true);
106+
return () => {
107+
window.removeEventListener('resize', updatePosition);
108+
window.removeEventListener('scroll', handleScroll, true);
109+
};
110+
}, [anchorRef, onRequestClose]);
111+
112+
return createPortal(
113+
<ul
114+
ref={setListRef}
115+
className="tw:fixed tw:z-30 tw:max-h-48 tw:overflow-y-auto tw:rounded-md tw:border tw:border-border tw:bg-popover tw:py-1 tw:shadow-md"
116+
id={listboxId}
117+
role="listbox"
118+
style={{ top: position.top, left: position.left }}
119+
>
120+
{entries.map((entry, index) => (
121+
<li
122+
key={entry.id}
123+
aria-label={
124+
index === 0
125+
? `Accept suggestion ${entry.gloss} for ${surfaceText}`
126+
: `Promote ${entry.gloss} for ${surfaceText}`
127+
}
128+
aria-selected={index === activeIndex}
129+
className={`tw:cursor-pointer tw:whitespace-nowrap tw:px-2 tw:py-0.5 tw:text-sm tw:italic ${index === 0 ? statusTextColorClass('suggested') : statusTextColorClass('candidate')}${index === activeIndex ? ' tw:bg-accent' : ''}`}
130+
data-testid={index === 0 ? 'suggestion-accept' : 'suggestion-candidate'}
131+
id={optionId(index)}
132+
role="option"
133+
// Select on mouse-down, suppressing its default focus shift, so choosing a row never blurs
134+
// the gloss input (the input keeps focus for the combobox) and the keyboard path stays the
135+
// only place Enter/arrow handling lives.
136+
onMouseDown={(e) => {
137+
e.preventDefault();
138+
onSelect(entry.id);
139+
}}
140+
onMouseEnter={() => onActiveIndexChange(index)}
141+
>
142+
{entry.gloss}
143+
</li>
144+
))}
145+
</ul>,
146+
document.body,
147+
);
148+
}

0 commit comments

Comments
 (0)