Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from 'react';
import { useI18n } from '../i18n';
import type { Move } from './GridCell';
export interface LinkOption {
value: string;
label: string;
}
/**
* The foreign-key editor: a search field in the cell, the rows the column may
* point at in a panel under it.
*
* A custom element rather than a `<select>`, because a select can only offer a
* list — and five hundred rows without a search field is a scroll, not a
* choice. The keys keep the grid's contract: Enter writes the highlighted row
* and moves down, Tab carries on sideways, Escape hands the keys back, and a
* click writes at once. Tab writes the highlight only once something has been
* typed or browsed to; untouched, it keeps what the cell already held, so
* tabbing across a row changes nothing it does not mean to.
*/
export function LinkPicker({
colName,
draft,
initialSearch,
options,
capped,
onCommit,
onCancel,
}: {
colName: string;
/** The row id the cell holds now — what an untouched Tab keeps. */
draft: string;
/** Typing on the selected cell opens the picker already searching for it. */
initialSearch: string;
options: LinkOption[];
/** True when the option list hit its fetch cap, so the search is partial. */
capped: boolean;
onCommit(value: string, move?: Move): void;
onCancel(byKey: boolean): void;
}): ReactNode {
const { t, locale } = useI18n();
const [search, setSearch] = useState(initialSearch);
const inputRef = useRef<HTMLInputElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const activeRef = useRef<HTMLButtonElement>(null);
/** See CellEditor: blur must not write (or cancel) twice after a key has. */
const settled = useRef(false);
/** Whether anything was typed or browsed to, so Tab knows what it keeps. */
const touched = useRef(false);
const listId = useId();
const needle = search.trim().toLowerCase();
const matches = needle
? options.filter((o) => o.label.toLowerCase().includes(needle))
: options;
// Clearing the cell is offered as the first row, but only while nothing has
// been typed: a search means looking for a row, not for the absence of one.
const entries: LinkOption[] = needle ? matches : [{ value: '', label: '—' }, ...matches];
const [highlight, setHighlight] = useState(() => {
const at = entries.findIndex((e) => e.value === draft);
return at >= 0 ? at : 0;
});
/**
* The panel escapes the cell. Grid cells clip their content, so the list is
* fixed-positioned from the cell's measured place — like the context menus —
* and re-measured while the grid scrolls under it. It opens upward when the
* room below the cell is not worth opening into.
*/
const [pos, setPos] = useState<CSSProperties | null>(null);
const place = useCallback(() => {
const rect = wrapRef.current?.closest('td')?.getBoundingClientRect();
if (!rect) return;
const style: CSSProperties = {
left: Math.max(6, Math.min(rect.left, window.innerWidth - 226)),
minWidth: Math.max(180, rect.width),
};
const below = window.innerHeight - rect.bottom;
if (below < 180 && rect.top > below) style.bottom = window.innerHeight - rect.top + 2;
else style.top = rect.bottom + 2;
setPos(style);
}, []);
useLayoutEffect(() => {
place();
window.addEventListener('scroll', place, true);
window.addEventListener('resize', place);
return () => {
window.removeEventListener('scroll', place, true);
window.removeEventListener('resize', place);
};
}, [place]);
useEffect(() => {
const input = inputRef.current;
input?.focus();
// The seed is what was just typed; the caret belongs after it.
input?.setSelectionRange(input.value.length, input.value.length);
}, []);
useEffect(() => {
// Optional-called: jsdom, where the tests run, does not implement it.
activeRef.current?.scrollIntoView?.({ block: 'nearest' });
}, [highlight]);
const commit = (value: string, move?: Move) => {
settled.current = true;
onCommit(value, move);
};
return (
<div ref={wrapRef} className="linkpick">
<input
ref={inputRef}
role="combobox"
aria-label={colName}
aria-expanded="true"
aria-controls={listId}
aria-activedescendant={`${listId}-${highlight}`}
aria-autocomplete="list"
placeholder={t('grid.searchPlaceholder')}
value={search}
onChange={(e) => {
touched.current = true;
setSearch(e.target.value);
setHighlight(0);
}}
onBlur={() => {
// Nothing here commits on blur: a search is not a value, and every
// way of picking one has already written it.
if (!settled.current) onCancel(false);
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
touched.current = true;
const step = e.key === 'ArrowDown' ? 1 : -1;
setHighlight((h) => Math.max(0, Math.min(entries.length - 1, h + step)));
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
commit(entries[highlight]?.value ?? draft, 'down');
} else if (e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
const keep = touched.current ? (entries[highlight]?.value ?? draft) : draft;
commit(keep, e.shiftKey ? 'left' : 'right');
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
settled.current = true;
onCancel(true);
} else {
e.stopPropagation();
}
}}
/>
{pos && (
<div
className="linkpick-panel"
role="listbox"
id={listId}
aria-label={t('grid.linkRows', colName)}
style={pos}
// Keep the focus in the search field, so a click cannot blur-cancel
// the editor before it lands on the row it is picking.
onMouseDown={(e) => e.preventDefault()}
>
{entries.map((entry, i) => (
<button
key={entry.value || '∅'}
type="button"
id={`${listId}-${i}`}
role="option"
aria-selected={entry.value === draft}
ref={i === highlight ? activeRef : undefined}
className={[
'hit',
i === highlight ? 'active' : '',
entry.value === '' ? 'clear' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => commit(entry.value)}
onMouseMove={() => setHighlight(i)}
>
{entry.label}
</button>
))}
{matches.length === 0 && (
<div className="linkpick-note">{t('grid.noLinkMatches')}</div>
)}
{capped && (
<div className="linkpick-note">{t('grid.linkCapped', options.length, locale)}</div>
)}
</div>
)}
</div>
);
}