The reference lookup: typing, the pause, and the popup
Updated
ReferenceLookup (frontend/src/components/ReferenceSelect.tsx) is the editor
of every reference value — record forms, table-part cells, report filters.
It is a text box the user can type into AND a dropdown whose popup is a
searchable record grid. Those two halves compete for the same keystrokes.
What opens and closes the popup#
| Gesture | Effect |
|---|---|
| Click anywhere in the field | opens (never toggles — a second click to move the caret keeps it open) |
| Click the chevron | toggles |
F4, ArrowDown |
opens |
Enter |
binds a unique match, otherwise opens the list |
Esc |
reverts the text to the current value and closes |
A typing pause of LOOKUP_TYPE_DEBOUNCE_MS (200 ms) |
binds a unique match; opens the list when the text is ambiguous |
| Clicking a row | binds and closes |
| Blur while closed | unique → bind, ambiguous → open, no match → revert |
Focus stays on the text input while the popup is open, so narrowing by typing is never interrupted.
The decisions are pure functions#
The component owns no matching logic. lib/formDataEntry.ts decides:
matchReferenceItems— ranks exact code, then code prefix, then a substring of the display text;resolveTypedLookupEnter/resolveTypedLookupBlur/resolveTypedLookupIdle— each returnscommit/open/revert/noop;lookupWindow/centredScrollTop— which rows the popup lists, and where it is scrolled when it opens.
They are unit-tested in lib/formDataEntry.test.ts; the component is tested in
components/ReferenceSelect.test.tsx. New behaviour belongs in a resolver, not
in an if inside the JSX.
The echoed value is not typing#
Warning
Focusing a filled lookup copies the current record's display text into the input. That text matches its own record — uniquely. So the idle resolver, 200 ms after the user clicked the field, "found a unique match", bound the value that was already bound and closed the list the user had just opened. From the user's side: the dropdown flashes open and hides itself again.
A typedByUser ref is the guard: only a real onChange from the input arms
the idle resolver. Focus, pick(), blur-revert, Esc and going disabled all
clear it. The paste / barcode-scanner path still works — a pasted value arrives
as an input event, which is real typing.
The general rule: state that means "the user did X" must not be set by code that merely restores a display. Echoing a value into a box the user can type in looks identical to typing, one render later.
The list opens onto the current value#
A filled lookup opens scrolled to its own record, centred in the popup, not
at row 1. Two pieces make that work, both pure functions in formDataEntry.ts:
lookupWindow(items, selectedIndex, limit)— the popup lists at mostLOOKUP_LIMIT(50) rows. When the current record sits past the cap the window slides to hold it, so the list always contains the value the field shows.centredScrollTop(viewport, row)— the scroll offset that centres the selected row, clamped at 0 so the first rows are not dragged out of reach.
The scroll is applied by hand on the ScrollArea viewport (viewportRef),
inside a requestAnimationFrame after the popup lays out. scrollIntoView()
is not used: it scrolls every scrollable ancestor, so the form behind the
popup jumps too. The row offsets are read from client rects, because a row's
offsetParent is the ScrollArea root, not the scrolling viewport.
The table header is stickyHeader for the same reason — a list that opens
mid-way would otherwise show no column titles.
Where the display text comes from#
useReferenceItems(dictionaryId)— the dictionary's list, refetched on every open so records created in another tab appear.useReferenceItem(dictionaryId, id)— resolves a single id. Needed because the current value may be outside the list (filtered, archived, paged out).useReferenceTexts(ids)— the same text as a plain string, for grids. See Grid columns.
The closed field shows displayValue: the dictionary's Display format
({Name} ({Code})). That string is what the user typed against, and what a
grid cell prints.
Columns in the popup#
The popup is a grid of Lookup properties, not one concatenated description.
On the dictionary (and document type) designer the Lookup properties tab
is a Mix LookupProperties list — the field names, in order. The same ticks
sit on the Properties / Header grid as a Lookup column.
When the list is empty the server fills it the Mix way: ID plus every
placeholder of Display format, then Name if nothing else remains. Each
reference item carries those fields in values. The popup headers are the
fields' captions (ID reads as Code). Typing in the description search box
matches any of those columns.
Another model can overlay that list without rewriting the owner CSV. On the
extension aggregate (lookupPropertiesAdd / lookupPropertiesHide) extras
append in layer order and hides drop names. The designer Lookup tab, when the
working model is not the owner, writes that overlay — ticks save immediately.
Display format still wins for the closed-field text. Clearing it falls back to joining the lookup columns with an em dash.
Where next#
- Grid columns: the same reference text, in a list.
- The forms system: how a field becomes a lookup.