textmachine/frontend/src/showcase/Bank.tsx

261 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Eye, EyeOff } from 'lucide-react';
import { useDeferredValue, useMemo, useState } from 'react';
import { termKind } from '../api';
import type { Bank as BankData, BankTerm } from '../api';
import { useLanguage } from '../i18n/language';
import { text, useText, type MessageKey } from '../i18n/text';
import { Chips, type Chip } from '../ui/Chips';
import { FilterField } from '../ui/FilterField';
import { Toggle } from '../ui/Toggle';
import { icon } from '../ui/icon';
import { measures } from '../tokens/measures';
import { Table, type TableColumn } from '../ui/Table';
import tableStyles from '../ui/Table.module.css';
import { counted, number } from './format';
import { matchesTerm } from './terms';
import styles from './Bank.module.css';
interface Props {
bank: BankData;
/** Source-side language of the book: without it the hanzi render with Japanese glyphs. */
sourceLang?: string;
/** The run stopped and waits for the bank to be signed — a state of the WHOLE bank. */
awaitingSignature?: boolean;
/**
* A request from the palette to show one term. NOT the value of the search box: the box owns its
* own text, and lifting it made every keystroke re-render the whole screen (measured: 150 ms per
* character on a book with 2284 chapters, because the tree re-rendered with it). The number is
* what makes asking for the SAME term twice a second request.
*/
request: { term: string; seq: number };
}
type KindFilter = 'all' | NonNullable<BankTerm['kind']> | 'undecided';
/** Plural forms for the bank counter; Intl picks the category. */
const terms: Record<Intl.LDMLPluralRule, MessageKey> = {
zero: 'bank.termsZero',
one: 'bank.termsOne',
two: 'bank.termsTwo',
few: 'bank.termsFew',
many: 'bank.termsMany',
other: 'bank.termsOther',
};
/** How far a column may grow to fit what it holds. */
const fitMax = measures['--column-fit-max'];
const kindFilters: { id: KindFilter; label: MessageKey }[] = [
{ id: 'all', label: 'bank.kindAll' },
...termKind.values.map((value) => ({ id: value, label: termKind.describe(value).label })),
{ id: 'undecided', label: termKind.describe(null).label },
];
/**
* The bank as a table: one row per term, columns that can be compared down the page, and the two
* ways through hundreds of rows — search and the kind filter. A book's names are a different
* table from its terms, and that is what the filter is for.
*
* ⚠ No per-term signing state on screen, deliberately. The engine keeps one per row and the
* contract carries it (`TermStatus`), but that is the MECHANISM under the stop: the product act is
* ONE signature over the whole bank, so a "signed" column made the screen argue with the product.
*/
export function Bank({ bank, sourceLang, awaitingSignature, request }: Props) {
const text = useText();
// The cells are drawn with words from the catalogue, and the collection cache is dropped only on
// the table's `dependencies` — so the interface language belongs in that list beside the rest.
const language = useLanguage((state) => state.language);
const [kind, setKind] = useState<KindFilter>('all');
// Descriptions are covered whole — they are the spoiler. Hover reveals one cell to the EYES;
// this toggle is the only way for the keyboard and for a screen reader, which a blur hides
// nothing from (while covered, the text is out of the accessibility tree).
const [senseShown, setSenseShown] = useState(false);
const [filter, setFilter] = useState(request.term);
// A request from the palette overrides both the typed text and the kind filter: the person just
// picked a term and expects to see it, not an empty table because another kind tab was on.
const [seen, setSeen] = useState(request.seq);
if (request.seq !== seen) {
setSeen(request.seq);
setFilter(request.term);
setKind('all');
}
// Typing stays responsive while the table lags a frame behind: rebuilding the collection over a
// thousand rows costs ~150 ms, and every keystroke would pay it.
const query = useDeferredValue(filter);
// Counted over what the SEARCH found rather than over the whole bank: otherwise a chip promises
// rows the current search does not have, and clicking it opens an empty table.
const found = useMemo(() => bank.terms.filter(matchesTerm(query)), [bank.terms, query]);
const chips: Chip[] = useMemo(() => {
const counts = countByKind(found);
return kindFilters.map((item) => ({
...item,
label: text(item.label),
count: counts[item.id] ?? 0,
}));
}, [found, text]);
const rows = useMemo(() => found.filter(matchesKind(kind)), [found, kind]);
// The kind column stands only while the filter shows every kind: on the `kind.name` tab a column
// that says `kind.name` in every row is noise.
const columns: TableColumn<BankTerm>[] = [
{
id: 'src',
name: text('bank.columnTerm'),
isRowHeader: true,
// The window is drawn as a SECOND span with a gap before it. Measured as one run of text it
// came up exactly that gap short, and the window clipped in every row that had one — so the
// gap is measured too, as the space it is.
fit: {
text: (term) => {
const window = windowOf(term);
return window === null ? term.src : `${term.src} ${window}`;
},
max: fitMax,
},
render: (term) => {
// The window travels with the term instead of taking a column: it is empty for almost
// every row, and an almost-empty column is width spent on nothing.
const window = windowOf(term);
return (
<>
<span className={styles.src} lang={sourceLang} title={term.src}>
{term.src}
</span>
{window !== null && (
<span className={styles.window} title={window}>
{window}
</span>
)}
</>
);
},
},
{
id: 'dst',
name: text('bank.columnTranslation'),
fit: {
text: (term) => (term.dst === '' ? text('bank.noTranslation') : term.dst),
max: fitMax,
},
// The table's only vertical rule is the language boundary: the book's side on the left.
className: tableStyles.divided,
render: (term) =>
term.dst === '' ? (
<span className={styles.absent}>{text('bank.noTranslation')}</span>
) : (
// Full text as a tooltip: a clipped cell is a dead end, and the translation is what is
// being checked here.
<span className={styles.dst} title={term.dst}>
{term.dst}
</span>
),
},
...(kind === 'all'
? [
{
id: 'kind',
name: text('bank.columnKind'),
// The widest label the bank actually uses, not the widest the vocabulary has: a book
// with no nicknames should not pay for the word.
fit: {
text: (term: BankTerm) => text(termKind.describe(term.kind).label),
max: fitMax,
},
render: (term: BankTerm) => (
<span className={styles.quiet}>{text(termKind.describe(term.kind).label)}</span>
),
},
]
: []),
{
id: 'sense',
// The spoiler is a property of the COLUMN, not of single rows: a term's sense gives away how
// things end, so the whole column is covered by default.
name: text('bank.columnSense'),
// No fit: a description is a sentence, and no width fits one — so this is the column with
// room to grow, and everything the others do not need lands here.
minWidth: measures['--column-min'],
className: senseShown ? undefined : styles.senseCell,
// Empty means "no disambiguator" — the contract says so since 0.2.2, so there is nothing to
// guess at here and no difference between an absent field and an empty one.
render: (term) =>
term.sense === '' ? (
<span className={styles.absent}></span>
) : (
<span className={styles.sense} data-sense="" aria-hidden={senseShown ? undefined : true}>
{term.sense}
</span>
),
},
];
return (
<div className={styles.bank}>
<div className={styles.toolbar}>
<div className={styles.search}>
<FilterField label={text('bank.search')} value={filter} onChange={setFilter} />
<Toggle
label={senseShown ? text('bank.hideSenses') : text('bank.showSenses')}
isSelected={senseShown}
onChange={setSenseShown}
>
{senseShown ? <Eye {...icon} /> : <EyeOff {...icon} />}
</Toggle>
</div>
<Chips
label={text('bank.kindFilter')}
items={chips}
selectedId={kind}
onSelect={(id) => setKind(id as KindFilter)}
/>
</div>
{/* ⚠ Everything the render closures take from outside the row belongs in `dependencies`, or
the collection cache serves cells drawn with the old value. */}
<Table
label={text('bank.tableLabel')}
columns={columns}
items={rows}
sample={bank.terms}
dependencies={[sourceLang, senseShown, language]}
empty={text('bank.empty')}
/>
{/* Untouched — the size of the bank; narrowed — how much of it is left. Otherwise the screen
shows two numbers about the same thing and explains neither. */}
<p className={styles.state}>
<span>
{rows.length === bank.total
? counted(bank.total, terms)
: text('bank.shown', { shown: number(rows.length), total: number(bank.total) })}
</span>
{awaitingSignature === true && <span>{text('bank.awaitingSignature')}</span>}
</p>
</div>
);
}
/** `0` means no boundary on that side, so a term without a window applies to the whole book. */
function windowOf({ since_chapter: since, until_chapter: until }: BankTerm): string | null {
if (since === 0 && until === 0) return null;
if (until === 0) return text('bank.windowSince', { chapter: number(since) });
if (since === 0) return text('bank.windowUntil', { chapter: number(until) });
return `${number(since)}${number(until)}`;
}
const matchesKind = (kind: KindFilter) => (term: BankTerm) => {
if (kind === 'all') return true;
if (kind === 'undecided') return term.kind === null;
return term.kind === kind;
};
function countByKind(terms: BankTerm[]): Record<string, number> {
const counts: Record<string, number> = { all: terms.length };
for (const term of terms) {
const key = term.kind ?? 'undecided';
counts[key] = (counts[key] ?? 0) + 1;
}
return counts;
}