-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCatalogRowView.tsx
More file actions
395 lines (376 loc) · 15.6 KB
/
Copy pathCatalogRowView.tsx
File metadata and controls
395 lines (376 loc) · 15.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
import { ChevronDown, ChevronRight, TriangleAlert } from 'lucide-react';
import {
Button,
Tooltip,
TooltipContent,
TooltipTrigger,
useTruncationTooltip,
} from 'platform-bible-react';
import { formatReplacementString, formatScrRef, type LanguageStrings } from 'platform-bible-utils';
import { memo, useCallback, useState } from 'react';
import CatalogRowEditor, { CatalogRowActions, ROW_EDITOR_STRING_KEYS } from './CatalogRowEditor';
import CatalogStaleLocations, { STALE_LOCATION_STRING_KEYS } from './CatalogStaleLocations';
import type { CatalogRow, CatalogUsage } from '../utils/analysis-query';
import { resolvedOrEmpty } from '../utils/localized-strings';
/**
* Localized string keys a row renders. Every row asks for the same strings, and subscribing per row
* would be a subscription per analysis in the draft, so they are resolved above the list and handed
* down.
*/
export const ROW_STRING_KEYS = [
'%interlinearizer_analysisCatalog_noGloss%',
'%interlinearizer_analysisCatalog_usageCount%',
'%interlinearizer_analysisCatalog_noUsages%',
'%interlinearizer_analysisCatalog_showAllUsages%',
'%interlinearizer_analysisCatalog_staleCount%',
...ROW_EDITOR_STRING_KEYS,
...STALE_LOCATION_STRING_KEYS,
] as const satisfies `%${string}%`[];
/**
* How many usages an expanded row lists before the rest go behind an expander. An analysis applied
* across a whole book has hundreds; listing them all would bury every row beneath it.
*/
const INLINE_USAGE_LIMIT = 12;
/** Props for {@link CatalogRowView}. */
type CatalogRowViewProps = Readonly<{
/** The analysis this row lists. */
row: CatalogRow;
/** Finished label for `row.usageCountInBook`, naming the book that count was taken against. */
usageCountInBookLabel: string;
/** Whether this is the row the view was last jumped from. */
isSelected: boolean;
/** Jumps the interlinear view to one of this analysis's usages. */
onUsageSelect: (analysisId: string, usage: CatalogUsage) => void;
/** Moves the interlinear view to the verse of a place this analysis went stale at. */
onStaleSelect: (analysisId: string, location: CatalogUsage) => void;
/** Gives up a place this analysis went stale at. */
onStaleDiscard: (analysisId: string, location: CatalogUsage) => void;
/** Moves this analysis from a place it went stale at onto the token at `tokenRef`. */
onStaleReapply: (
analysisId: string,
location: CatalogUsage,
tokenRef: string,
surfaceText: string,
) => void;
/** Reads the loaded book's current text for a token ref, `undefined` for one in any other book. */
liveSurfaceText: (tokenRef: string) => string | undefined;
/** Resolved localizations covering at least {@link ROW_STRING_KEYS}, shared by the whole list. */
localizedStrings: LanguageStrings;
/** BCP 47 tag the morpheme glosses are read under. */
analysisLanguage: string;
/** When false, the row's breakdown is not shown, as the view option hides it on the strip. */
showMorphology: boolean;
/** Writes this row's gloss for every token linked to it. */
onGlossCommit: (analysisId: string, value: string) => void;
/** Replaces this row's morpheme breakdown for every token linked to it. */
onMorphemesCommit: (analysisId: string, forms: readonly string[]) => void;
/** Writes one of this row's morpheme glosses for every token linked to it. */
onMorphemeGlossCommit: (analysisId: string, morphemeId: string, value: string) => void;
/**
* Opens the merge picker for this row. Absent when the analysis shares its form with no other
* record, which is how the merge control is withheld from a row with nothing to merge with.
*/
onMergeRequest?: (analysisId: string) => void;
/** Opens the delete confirmation for this row. */
onDeleteRequest: (analysisId: string) => void;
/**
* Whether the row should be scrolled into view. Set on the row a merge-on-edit left standing, so
* the reader is taken to where their edit went rather than left where it vanished from.
*/
shouldRevealSelf?: boolean;
/** This row's breakdown draft, or `undefined` while its breakdown editor is closed. */
breakdownDraft: string | undefined;
/**
* Records this row's breakdown draft, `undefined` closing its breakdown editor. Reports the
* analysis's surface form alongside it, which names the draft once its record is gone.
*/
onBreakdownDraftChange: (
analysisId: string,
draft: string | undefined,
surfaceText: string,
) => void;
}>;
/** Renders a place's verse the way scripture references are written, e.g. `GEN 1:1`. */
function usageLabel(usage: CatalogUsage): string {
return formatScrRef({
book: usage.book,
chapterNum: usage.chapter,
verseNum: usage.verse,
});
}
/**
* One analysis in the catalog: its surface form and gloss, and how much of the draft it accounts
* for — the whole draft's usage count beside the current book's. Expanding it reveals the controls
* for editing the analysis and the places it is applied.
*
* Each row owns its own layout so that its detail can be nested inside it.
*/
function CatalogRowView({
row,
usageCountInBookLabel,
isSelected,
onUsageSelect,
onStaleSelect,
onStaleDiscard,
onStaleReapply,
liveSurfaceText,
localizedStrings,
analysisLanguage,
showMorphology,
onGlossCommit,
onMorphemesCommit,
onMorphemeGlossCommit,
onMergeRequest,
onDeleteRequest,
shouldRevealSelf = false,
breakdownDraft,
onBreakdownDraftChange,
}: CatalogRowViewProps) {
const [isExpanded, setIsExpanded] = useState(false);
/** Whether the usage list is showing every usage rather than the first {@link INLINE_USAGE_LIMIT}. */
const [showsAllUsages, setShowsAllUsages] = useState(false);
// Collapsing returns the row to the inline cap: without it a row once expanded to hundreds of
// usages has no way back, since the expander it was opened from is gone.
const handleToggle = useCallback(() => {
setIsExpanded((expanded) => !expanded);
setShowsAllUsages(false);
}, []);
// The editor is handed callbacks already carrying this row's id, so it never needs the id itself
// to report an edit.
const { analysisId } = row;
const handleGlossCommit = useCallback(
(value: string) => onGlossCommit(analysisId, value),
[analysisId, onGlossCommit],
);
const handleMorphemesCommit = useCallback(
(forms: readonly string[]) => onMorphemesCommit(analysisId, forms),
[analysisId, onMorphemesCommit],
);
const handleMorphemeGlossCommit = useCallback(
(morphemeId: string, value: string) => onMorphemeGlossCommit(analysisId, morphemeId, value),
[analysisId, onMorphemeGlossCommit],
);
const handleMergeRequest = useCallback(
() => onMergeRequest?.(analysisId),
[analysisId, onMergeRequest],
);
const handleDeleteRequest = useCallback(
() => onDeleteRequest(analysisId),
[analysisId, onDeleteRequest],
);
const handleStaleSelect = useCallback(
(location: CatalogUsage) => onStaleSelect(analysisId, location),
[analysisId, onStaleSelect],
);
const handleStaleDiscard = useCallback(
(location: CatalogUsage) => onStaleDiscard(analysisId, location),
[analysisId, onStaleDiscard],
);
const handleStaleReapply = useCallback(
(location: CatalogUsage, tokenRef: string, targetSurfaceText: string) =>
onStaleReapply(analysisId, location, tokenRef, targetSurfaceText),
[analysisId, onStaleReapply],
);
const { surfaceText } = row;
const handleBreakdownDraftChange = useCallback(
(draft: string | undefined) => onBreakdownDraftChange(analysisId, draft, surfaceText),
[analysisId, onBreakdownDraftChange, surfaceText],
);
const visibleUsages = showsAllUsages ? row.usages : row.usages.slice(0, INLINE_USAGE_LIMIT);
const hiddenUsageCount = row.usages.length - visibleUsages.length;
const usageCountLabel = localizedStrings['%interlinearizer_analysisCatalog_usageCount%'];
const staleCountLabel = localizedStrings['%interlinearizer_analysisCatalog_staleCount%'];
// This is visible cell text, so blanking an unresolved key would empty the gloss column. The em
// dash reads as "no gloss" in any language and stands in until the lookup lands.
const glossLabel =
row.gloss ||
resolvedOrEmpty(localizedStrings['%interlinearizer_analysisCatalog_noGloss%']) ||
'—';
// One tooltip each rather than one for the row: either column may be the clipped one, and a
// tooltip is worth opening only over the text that is actually cut off.
const surfaceTooltip = useTruncationTooltip<HTMLSpanElement>();
const glossTooltip = useTruncationTooltip<HTMLSpanElement>();
/**
* Brings the row into view once the panel asks for it, which it does for the row a merge-on-edit
* left standing. Runs on the flag turning true rather than on every render, so a reader who then
* scrolls away is not dragged back by an unrelated re-render.
*/
const revealRef = useCallback(
(el: HTMLLIElement | null) => {
/* v8 ignore next -- jsdom implements no layout, so scrollIntoView is absent on the element */
if (shouldRevealSelf) el?.scrollIntoView?.({ block: 'nearest' });
},
[shouldRevealSelf],
);
return (
<li
ref={revealRef}
className={`tw:flex tw:flex-col tw:border-b tw:border-border ${
isSelected ? 'tw:bg-accent/50' : ''
}`}
data-analysis-id={row.analysisId}
data-selected={String(isSelected)}
data-testid="catalog-row"
>
{/*
Carries no `aria-label`: a name on a button overrides its content, so one here would
announce every row alike and suppress the analysis each lists.
*/}
<Button
aria-expanded={isExpanded}
// Overrides the platform button's own box: this is a row of the list, not a control
// sitting in one.
className="tw:flex tw:h-auto tw:w-full tw:items-baseline tw:justify-start tw:gap-2 tw:rounded-none tw:px-3 tw:py-2 tw:text-start tw:font-normal"
data-testid="catalog-row-toggle"
onClick={handleToggle}
type="button"
variant="ghost"
>
{isExpanded ? (
<ChevronDown className="tw:size-3 tw:shrink-0" />
) : (
<ChevronRight className="tw:size-3 tw:shrink-0" />
)}
<Tooltip open={surfaceTooltip.open}>
<TooltipTrigger asChild>
<span
ref={surfaceTooltip.ref}
className="tw:flex-1 tw:min-w-0 tw:truncate tw:font-medium"
data-testid="catalog-row-surface"
onPointerEnter={surfaceTooltip.onPointerEnter}
onPointerLeave={surfaceTooltip.onPointerLeave}
>
{row.surfaceText}
</span>
</TooltipTrigger>
<TooltipContent>{row.surfaceText}</TooltipContent>
</Tooltip>
<Tooltip open={glossTooltip.open}>
<TooltipTrigger asChild>
<span
ref={glossTooltip.ref}
className="tw:flex-1 tw:min-w-0 tw:truncate tw:text-sm tw:text-muted-foreground"
data-testid="catalog-row-gloss"
onPointerEnter={glossTooltip.onPointerEnter}
onPointerLeave={glossTooltip.onPointerLeave}
>
{glossLabel}
</span>
</TooltipTrigger>
<TooltipContent>{glossLabel}</TooltipContent>
</Tooltip>
{/*
Native `title` rather than the platform Tooltip because these counts sit inside the row's
own button, where a tooltip trigger would nest one interactive element in another. A
`title` on a span is not reliably announced, hence the screen-reader-only labels.
*/}
<span
className="tw:text-xs tw:tabular-nums"
data-testid="catalog-row-usage-count"
title={usageCountLabel}
>
{row.usageCount}
<span className="tw:sr-only">{` ${usageCountLabel}`}</span>
</span>
<span
className="tw:text-xs tw:tabular-nums tw:text-muted-foreground"
data-testid="catalog-row-usage-count-in-book"
title={usageCountInBookLabel}
>
{row.usageCountInBook}
<span className="tw:sr-only">{` ${usageCountInBookLabel}`}</span>
</span>
{row.staleLocations.length > 0 && (
<span
className="tw:flex tw:items-center tw:gap-0.5 tw:text-xs tw:tabular-nums tw:gloss-stale"
data-testid="catalog-row-stale-count"
title={staleCountLabel}
>
<TriangleAlert className="tw:size-3" />
{row.staleLocations.length}
<span className="tw:sr-only">{` ${staleCountLabel}`}</span>
</span>
)}
</Button>
{isExpanded && (
<div
className="tw:flex tw:flex-col tw:gap-2 tw:px-3 tw:pb-2 tw:ps-8"
data-testid="catalog-row-detail"
>
<CatalogRowEditor
analysisId={row.analysisId}
analysisLanguage={analysisLanguage}
breakdownDraft={breakdownDraft}
gloss={row.gloss}
localizedStrings={localizedStrings}
morphemes={row.morphemes}
showMorphology={showMorphology}
onBreakdownDraftChange={handleBreakdownDraftChange}
onGlossCommit={handleGlossCommit}
onMorphemeGlossCommit={handleMorphemeGlossCommit}
onMorphemesCommit={handleMorphemesCommit}
surfaceText={row.surfaceText}
usageCount={row.usageCount}
/>
{row.usages.length === 0 && row.staleLocations.length === 0 && (
<p className="tw:text-xs tw:text-muted-foreground">
{localizedStrings['%interlinearizer_analysisCatalog_noUsages%']}
</p>
)}
{row.usages.length > 0 && (
<div className="tw:flex tw:flex-wrap tw:gap-1">
{visibleUsages.map((usage) => (
<Button
className="tw:h-auto tw:px-1 tw:py-0 tw:text-xs"
data-testid="catalog-usage"
data-token-ref={usage.tokenRef}
key={usage.tokenRef}
onClick={() => onUsageSelect(row.analysisId, usage)}
size="sm"
variant="link"
>
{usageLabel(usage)}
</Button>
))}
{hiddenUsageCount > 0 && (
<Button
data-testid="catalog-usages-show-all"
onClick={() => setShowsAllUsages(true)}
size="sm"
variant="link"
>
{formatReplacementString(
localizedStrings['%interlinearizer_analysisCatalog_showAllUsages%'],
{ count: hiddenUsageCount },
)}
</Button>
)}
</div>
)}
{row.staleLocations.length > 0 && (
<CatalogStaleLocations
inlineLimit={INLINE_USAGE_LIMIT}
labelFor={usageLabel}
liveSurfaceText={liveSurfaceText}
localizedStrings={localizedStrings}
locations={row.staleLocations}
onDiscard={handleStaleDiscard}
onReapply={handleStaleReapply}
onSelect={handleStaleSelect}
/>
)}
{/* Below the usages, so what a merge or delete is about to take is in view above the
button taking it. */}
<CatalogRowActions
localizedStrings={localizedStrings}
onDeleteRequest={handleDeleteRequest}
onMergeRequest={onMergeRequest ? handleMergeRequest : undefined}
/>
</div>
)}
</li>
);
}
/** Memoized version of {@link CatalogRowView}; use in render-stable row lists. */
const MemoizedCatalogRowView = memo(CatalogRowView);
export default MemoizedCatalogRowView;