{
+ ref(element);
+ // Bound after the hook's, and in the bubble phase, as the platform's listener is: it
+ // stands down only for a press the hook has already claimed.
+ element?.addEventListener('keydown', (event) => {
+ if (event.defaultPrevented) return;
+ platformSteps(event.key);
+ });
+ }}
+ role="separator"
+ // Focusable as the real separator is, which is what puts key presses within its reach.
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
+ tabIndex={0}
+ />
+ );
+ }
+ render(
);
+ return { handle: screen.getByTestId('handle'), platformSteps };
+}
+
+/** Presses `key` on the handle, with `init` supplying any modifiers held. */
+function press(handle: HTMLElement, key: string, init: object = {}): void {
+ fireEvent.keyDown(handle, { key, ...init });
+}
+
+describe('usePanelResizeKeys', () => {
+ afterEach(() => {
+ document.documentElement.removeAttribute('dir');
+ });
+
+ describe('in a left-to-right interface', () => {
+ it('leaves the arrows to the platform handle, which already reads them correctly', () => {
+ const onPercentageChange = jest.fn();
+ const { handle, platformSteps } = renderHandle(25, onPercentageChange);
+
+ press(handle, 'ArrowLeft');
+
+ expect(onPercentageChange).not.toHaveBeenCalled();
+ expect(platformSteps).toHaveBeenCalledWith('ArrowLeft');
+ });
+ });
+
+ describe('in a right-to-left interface', () => {
+ beforeEach(() => {
+ document.documentElement.dir = 'rtl';
+ });
+
+ it('narrows the panel on ArrowLeft, which points away from the edge it is anchored to', () => {
+ const onPercentageChange = jest.fn();
+ const { handle } = renderHandle(25, onPercentageChange);
+
+ press(handle, 'ArrowLeft');
+
+ expect(onPercentageChange).toHaveBeenCalledWith(20);
+ });
+
+ it('widens the panel on ArrowRight', () => {
+ const onPercentageChange = jest.fn();
+ const { handle } = renderHandle(25, onPercentageChange);
+
+ press(handle, 'ArrowRight');
+
+ expect(onPercentageChange).toHaveBeenCalledWith(30);
+ });
+
+ it('claims the mirrored arrow, so the platform handle does not step it a second time', () => {
+ const { handle, platformSteps } = renderHandle(25, () => {});
+
+ press(handle, 'ArrowRight');
+
+ expect(platformSteps).not.toHaveBeenCalled();
+ });
+
+ it('narrows the panel fully on Home, landing where ArrowLeft points', () => {
+ const onPercentageChange = jest.fn();
+ const { handle } = renderHandle(25, onPercentageChange);
+
+ press(handle, 'Home');
+
+ expect(onPercentageChange).toHaveBeenCalledWith(BOUNDS.min);
+ });
+
+ it('widens the panel fully on End, landing where ArrowRight points', () => {
+ const onPercentageChange = jest.fn();
+ const { handle } = renderHandle(25, onPercentageChange);
+
+ press(handle, 'End');
+
+ expect(onPercentageChange).toHaveBeenCalledWith(BOUNDS.max);
+ });
+
+ it('claims the mirrored jump key, so the platform handle does not step it a second time', () => {
+ const { handle, platformSteps } = renderHandle(25, () => {});
+
+ press(handle, 'Home');
+
+ expect(platformSteps).not.toHaveBeenCalled();
+ });
+
+ it('reports nothing for a jump key pressed at the bound it lands on', () => {
+ const onPercentageChange = jest.fn();
+ const { handle } = renderHandle(BOUNDS.max, onPercentageChange);
+
+ press(handle, 'End');
+
+ expect(onPercentageChange).not.toHaveBeenCalled();
+ });
+
+ it('holds a widening arrow to the widest the panel may be', () => {
+ const onPercentageChange = jest.fn();
+ const { handle } = renderHandle(48, onPercentageChange);
+
+ press(handle, 'ArrowRight');
+
+ expect(onPercentageChange).toHaveBeenCalledWith(50);
+ });
+
+ it('reports nothing for an arrow held down at the end of the range', () => {
+ const onPercentageChange = jest.fn();
+ const { handle } = renderHandle(50, onPercentageChange);
+
+ press(handle, 'ArrowRight');
+
+ expect(onPercentageChange).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('jump keys in a left-to-right interface', () => {
+ it.each(['Home', 'End'])('leaves %s to the platform handle, which jumps to an end', (key) => {
+ const onPercentageChange = jest.fn();
+ const { handle, platformSteps } = renderHandle(25, onPercentageChange);
+
+ press(handle, key);
+
+ expect(onPercentageChange).not.toHaveBeenCalled();
+ expect(platformSteps).toHaveBeenCalledWith(key);
+ });
+ });
+
+ describe('keys it does not act on', () => {
+ it('leaves the panel alone on a key that resizes nothing', () => {
+ const onPercentageChange = jest.fn();
+ const { handle } = renderHandle(25, onPercentageChange);
+
+ press(handle, 'a');
+
+ expect(onPercentageChange).not.toHaveBeenCalled();
+ });
+
+ it.each(['metaKey', 'altKey', 'ctrlKey'])(
+ 'leaves a %s-modified arrow for the host to act on',
+ (modifier) => {
+ document.documentElement.dir = 'rtl';
+ const onPercentageChange = jest.fn();
+ const { handle, platformSteps } = renderHandle(25, onPercentageChange);
+
+ press(handle, 'ArrowRight', { [modifier]: true });
+
+ expect(onPercentageChange).not.toHaveBeenCalled();
+ expect(platformSteps).toHaveBeenCalledWith('ArrowRight');
+ },
+ );
+ });
+
+ it('resizes from the percentage it is given rather than one it remembers', () => {
+ // The caller holds the layout, so a percentage changed elsewhere — by a drag, or by a restored
+ // layout — is what the next press has to step from.
+ document.documentElement.dir = 'rtl';
+ const onPercentageChange = jest.fn();
+
+ function Handle({ percentage }: Readonly<{ percentage: number }>) {
+ const ref = usePanelResizeKeys(percentage, onPercentageChange, BOUNDS);
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
+ return
;
+ }
+ const { rerender } = render(
);
+ rerender(
);
+
+ press(screen.getByTestId('handle'), 'ArrowRight');
+
+ expect(onPercentageChange).toHaveBeenCalledWith(45);
+ });
+
+ it('stops resizing once the handle it was on has gone', () => {
+ // The catalog's handle is unmounted when the panel closes, and a listener left bound to it
+ // would keep answering presses for a panel that is no longer there.
+ document.documentElement.dir = 'rtl';
+ const onPercentageChange = jest.fn();
+
+ function Handle({ present }: Readonly<{ present: boolean }>) {
+ const ref = usePanelResizeKeys(25, onPercentageChange, BOUNDS);
+ return present ? (
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
+
+ ) : undefined;
+ }
+ const { rerender } = render(
);
+ const handle = screen.getByTestId('handle');
+ rerender(
);
+
+ press(handle, 'ArrowRight');
+
+ expect(onPercentageChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts
index e4dd7abb..65c807c7 100644
--- a/src/__tests__/main.test.ts
+++ b/src/__tests__/main.test.ts
@@ -267,6 +267,7 @@ describe('main', () => {
'interlinearizer.openSelectProjectModal',
'interlinearizer.openNewProjectModal',
'interlinearizer.openProjectInfoModal',
+ 'interlinearizer.openAnalysisCatalog',
'interlinearizer.updateProjectMetadata',
'interlinearizer.deleteProject',
]),
diff --git a/src/__tests__/test-helpers.ts b/src/__tests__/test-helpers.ts
index 02fa2191..7d5b47cd 100644
--- a/src/__tests__/test-helpers.ts
+++ b/src/__tests__/test-helpers.ts
@@ -2,6 +2,7 @@ import type { SerializedVerseRef } from '@sillsdev/scripture';
import type { ExecutionActivationContext, UseWebViewScrollGroupScrRefHook } from '@papi/core';
import type { Book, InterlinearProject, PhraseAnalysisLink, Segment, Token } from 'interlinearizer';
import { UnsubscriberAsyncList } from 'platform-bible-utils';
+import { useEffect, useState } from 'react';
import { tokenizeBook } from 'parsers/papi/bookTokenizer';
import type { RawBook } from 'parsers/papi/usjBookExtractor';
import {
@@ -31,11 +32,34 @@ type StateSlot
= { get: () => T; set: (v: T) => void };
/**
* Returns a `useWebViewState` hook stub that stores values in typed per-key closures so state
* persists across re-renders within the same test without requiring any type assertions.
+ *
+ * A write re-renders every component reading the returned hook, as the real PAPI hook does. Without
+ * that, a value only reachable through this state (a panel's open flag, say) could be written but
+ * never seen, and its test would fail for a reason the production code has nothing to do with.
+ * Notification is store-wide rather than per key: a test render tree is small enough that the extra
+ * renders cost nothing, and keying it would be a second thing to keep right.
+ *
+ * A reset re-renders every reader as a write does, and lands on the resetting caller's default
+ * rather than on the `seed` this stub opened with, matching what a real reset leaves behind.
*/
export function makeWebViewState(seed: Record = {}) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const slots = new Map>();
+ const listeners = new Set<() => void>();
return (key: string, defaultValue: T): [T, (v: T) => void, () => void] => {
+ // Subscribes this caller to writes. Legitimate hook use: the stub stands in for a hook and is
+ // only ever called from a component's render, exactly as the hook it replaces is.
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const [, bumpRenderCount] = useState(0);
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ useEffect(() => {
+ const listener = () => bumpRenderCount((count) => count + 1);
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ }, []);
+
let slot: StateSlot | undefined = slots.get(key);
if (slot === undefined) {
// eslint-disable-next-line no-type-assertion/no-type-assertion
@@ -44,6 +68,7 @@ export function makeWebViewState(seed: Record = {}) {
get: () => stored,
set: (v) => {
stored = v;
+ listeners.forEach((listener) => listener());
},
};
slots.set(key, slot);
@@ -52,9 +77,7 @@ export function makeWebViewState(seed: Record = {}) {
return [
resolvedSlot.get(),
(v: T) => resolvedSlot.set(v),
- () => {
- slots.delete(key);
- },
+ () => resolvedSlot.set(defaultValue),
];
};
}
diff --git a/src/__tests__/utils/analysis-query.test.ts b/src/__tests__/utils/analysis-query.test.ts
index 51bb0a52..0689d492 100644
--- a/src/__tests__/utils/analysis-query.test.ts
+++ b/src/__tests__/utils/analysis-query.test.ts
@@ -1,6 +1,7 @@
///
import type { AssignmentStatus, TextAnalysis, TokenAnalysisLink } from 'interlinearizer';
+import { Collator } from 'platform-bible-utils';
import { emptyAnalysis } from '../../types/empty-factories';
import { FIXTURE_STAMPS } from '../test-helpers';
import {
@@ -19,8 +20,8 @@ function makeQuery(overrides: Partial = {}): CatalogQuery {
search: '',
sort: 'usageCount',
filters: {},
- surfaceCollator: new Intl.Collator('el'),
- glossCollator: new Intl.Collator('en'),
+ surfaceCollator: new Collator('el'),
+ glossCollator: new Collator('en'),
...overrides,
};
}
@@ -390,11 +391,11 @@ describe('applyCatalogQuery sort', () => {
],
};
const rows = buildCatalogRows(glossed, scope);
- const sortByGloss = (glossCollator: Intl.Collator) =>
+ const sortByGloss = (glossCollator: Collator) =>
applyCatalogQuery(rows, makeQuery({ sort: 'gloss', glossCollator })).map((r) => r.analysisId);
- expect(sortByGloss(new Intl.Collator('sv'))).toEqual(['ta-2', 'ta-1']);
- expect(sortByGloss(new Intl.Collator('en'))).toEqual(['ta-1', 'ta-2']);
+ expect(sortByGloss(new Collator('sv'))).toEqual(['ta-2', 'ta-1']);
+ expect(sortByGloss(new Collator('en'))).toEqual(['ta-1', 'ta-2']);
});
// Collating alone would open the list with ta-2: a missing gloss is the empty string, which sorts
diff --git a/src/__tests__/utils/language-tags.test.ts b/src/__tests__/utils/language-tags.test.ts
new file mode 100644
index 00000000..74608641
--- /dev/null
+++ b/src/__tests__/utils/language-tags.test.ts
@@ -0,0 +1,17 @@
+///
+
+import { collatorForTag } from '../../utils/language-tags';
+
+describe('collatorForTag', () => {
+ it('collates under the tag it is given', () => {
+ // Swedish sorts "ä" after "z", which the default locale does not, so the tag demonstrably
+ // reached the collator rather than being dropped.
+ expect(collatorForTag('sv').compare('ä', 'z')).toBeGreaterThan(0);
+ });
+
+ it('falls back to the default collation for a tag Intl rejects', () => {
+ // Underscores instead of hyphens is the classic hand-typed tag, and `Intl` throws on it.
+ expect(() => collatorForTag('en_US')).not.toThrow();
+ expect(collatorForTag('en_US').compare('a', 'b')).toBeLessThan(0);
+ });
+});
diff --git a/src/components/AnalysisCatalogPanel.tsx b/src/components/AnalysisCatalogPanel.tsx
new file mode 100644
index 00000000..f6aae721
--- /dev/null
+++ b/src/components/AnalysisCatalogPanel.tsx
@@ -0,0 +1,176 @@
+import { useLocalizedStrings } from '@papi/frontend/react';
+import { Canon } from '@sillsdev/scripture';
+import { X } from 'lucide-react';
+import { Button, EmptyState, TooltipProvider } from 'platform-bible-react';
+import { formatReplacementString } from 'platform-bible-utils';
+import { useCallback, useMemo, useState } from 'react';
+import { useAnalysisLanguage, useCatalogRows } from './AnalysisStore';
+import CatalogRowView, { ROW_STRING_KEYS } from './CatalogRowView';
+import { useInterlinearNav } from './InterlinearNavContext';
+import { applyCatalogQuery, type CatalogQuery, type CatalogUsage } from '../utils/analysis-query';
+import { collatorForTag } from '../utils/language-tags';
+
+/**
+ * Localized string keys the panel needs, the rows' among them so the list resolves once rather than
+ * once per analysis. Hoisted to module scope so the reference passed to `useLocalizedStrings` is
+ * stable across renders; a fresh array literal each render makes the PAPI hook re-fetch and re-set
+ * state every render.
+ */
+const STRING_KEYS = [
+ '%interlinearizer_analysisCatalog_title%',
+ '%interlinearizer_analysisCatalog_close%',
+ '%interlinearizer_analysisCatalog_resize%',
+ '%interlinearizer_analysisCatalog_empty%',
+ '%interlinearizer_analysisCatalog_usageCountInBook%',
+ ...ROW_STRING_KEYS,
+] as const satisfies `%${string}%`[];
+
+/** Props for {@link AnalysisCatalogPanel}. */
+type AnalysisCatalogPanelProps = Readonly<{
+ /** Dismisses the panel. */
+ onClose: () => void;
+ /** Book code each row's per-book usage count is taken against. */
+ currentBook: string;
+ /** BCP 47 tag of the source text, so surface forms collate by their own language. */
+ sourceLanguageTag: string;
+}>;
+
+/**
+ * The analysis catalog: every analysis the draft records, listed with the usage data the catalog
+ * lists it by. Read-only — nothing here writes to the analysis.
+ *
+ * Sits beside the interlinear view rather than over it, so a jump to a usage can move the view
+ * while the list the jump came from stays on screen.
+ */
+export default function AnalysisCatalogPanel({
+ onClose,
+ currentBook,
+ sourceLanguageTag,
+}: AnalysisCatalogPanelProps) {
+ const [localizedStrings] = useLocalizedStrings(STRING_KEYS);
+ const analysisLanguage = useAnalysisLanguage();
+ const catalogRows = useCatalogRows(currentBook);
+
+ /**
+ * How the listing is narrowed and ordered: unnarrowed, most-used first. The panel offers no
+ * control over any of it, so the values here are the whole of what the reader gets.
+ */
+ const query = useMemo(
+ () => ({
+ search: '',
+ sort: 'usageCount',
+ filters: {},
+ surfaceCollator: collatorForTag(sourceLanguageTag),
+ glossCollator: collatorForTag(analysisLanguage),
+ }),
+ [sourceLanguageTag, analysisLanguage],
+ );
+
+ const rows = useMemo(() => applyCatalogQuery(catalogRows, query), [catalogRows, query]);
+
+ /**
+ * The current book's name key, asked for separately from {@link STRING_KEYS} so that changing book
+ * re-resolves this alone rather than every string the panel shows.
+ */
+ const bookNameKeys = useMemo(
+ () => [`%LocalizedId.${currentBook}%`] as const satisfies `%${string}%`[],
+ [currentBook],
+ );
+ const [localizedBookName] = useLocalizedStrings(bookNameKeys);
+
+ /**
+ * Label every row carries for its per-book usage count, resolved once for the whole list. Names
+ * the book rather than giving its code, because this label reads as prose where the usage links
+ * below it read as references.
+ *
+ * Falls back to the English name, the platform carrying a localized one for only some languages.
+ * An unresolved key comes back as itself, which is what distinguishes the two.
+ */
+ const usageCountInBookLabel = useMemo(() => {
+ const [bookKey] = bookNameKeys;
+ const resolved = localizedBookName?.[bookKey];
+ return formatReplacementString(
+ localizedStrings['%interlinearizer_analysisCatalog_usageCountInBook%'],
+ {
+ book: resolved && resolved !== bookKey ? resolved : Canon.bookIdToEnglishName(currentBook),
+ },
+ );
+ }, [localizedStrings, localizedBookName, bookNameKeys, currentBook]);
+
+ const { navigate, requestFocusToken } = useInterlinearNav();
+
+ /**
+ * The analysis whose usage was last jumped to, or `undefined` before any jump. Marks where in the
+ * list the view came from, so a jump that scrolls the text away does not also lose the reader's
+ * place in the catalog.
+ */
+ const [selectedAnalysisId, setSelectedAnalysisId] = useState(undefined);
+
+ /**
+ * Moves the interlinear view to a usage: the verse it sits in, then the token itself.
+ *
+ * The focus request is raised before the navigation so that it is already pending when the
+ * reference moves. A request is abandoned only once the reference names a book other than the one
+ * the request does, so a cross-book jump leaves it outstanding until that book's view mounts and
+ * claims it.
+ *
+ * The navigation is external — the default — because a usage may name any verse in the draft, so
+ * the view has to recenter on it rather than track it in place.
+ */
+ const handleUsageSelect = useCallback(
+ (analysisId: string, usage: CatalogUsage) => {
+ setSelectedAnalysisId(analysisId);
+ requestFocusToken(usage.tokenRef);
+ navigate({ book: usage.book, chapterNum: usage.chapter, verseNum: usage.verse });
+ },
+ [navigate, requestFocusToken],
+ );
+
+ return (
+ // The panel sits beside the interlinear view rather than within it, so the row tooltips have no
+ // enclosing provider to inherit, and a Tooltip without one throws. The delay is irrelevant here:
+ // these tooltips open on truncation rather than on hover time.
+
+
+
+
+ {localizedStrings['%interlinearizer_analysisCatalog_title%']}
+
+
+
+
+
+
+ {rows.length === 0 ? (
+
+ ) : (
+
+ {rows.map((row) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx
index bc233e2a..eda99e79 100644
--- a/src/components/AnalysisStore.tsx
+++ b/src/components/AnalysisStore.tsx
@@ -18,6 +18,7 @@ import {
selectAnalysisLanguage,
selectApprovedGloss,
selectApprovedMorphemes,
+ selectCatalogRows,
selectMorphemeResetLosesGlosses,
selectPhraseLinkByAnalysisId,
selectPhraseLinkByTokenRef,
@@ -33,6 +34,7 @@ import {
writeSegmentFreeTranslation,
} from '../store/analysisSlice';
import { emptyAnalysis } from '../types/empty-factories';
+import type { CatalogRow } from '../utils/analysis-query';
import { resolvedTokenAnalysisEqual, type ResolvedTokenAnalysis } from '../utils/suggestion-engine';
// #region Internal context
@@ -364,6 +366,23 @@ export function useMorphemeResetLosesGlosses(tokenRef: string): boolean {
);
}
+/**
+ * Returns one row per distinct token analysis in the draft, each carrying the usage data the
+ * analysis catalog lists it by, in the analysis's own order. Narrowing and ordering are the
+ * caller's, so a keystroke re-runs only that pass.
+ *
+ * The result keeps its reference while the analyses and their links keep theirs, so an unrelated
+ * write — a free translation, a phrase link — leaves the list unrendered. It changes with
+ * `currentBook`, which the per-book usage count is taken against.
+ *
+ * @throws When called outside an {@link AnalysisStoreProvider}.
+ */
+export function useCatalogRows(currentBook: string): readonly CatalogRow[] {
+ useRequiredCallbacks('useCatalogRows');
+
+ return useSelector((state: AnalysisRootState) => selectCatalogRows(state.analysis, currentBook));
+}
+
/**
* Returns the active BCP 47 analysis-language tag from the nearest {@link AnalysisStoreProvider}.
*
diff --git a/src/components/CatalogRowView.tsx b/src/components/CatalogRowView.tsx
new file mode 100644
index 00000000..dea01c1f
--- /dev/null
+++ b/src/components/CatalogRowView.tsx
@@ -0,0 +1,241 @@
+import { ChevronDown, ChevronRight } 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 type { CatalogRow, CatalogUsage } from '../utils/analysis-query';
+
+/**
+ * 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%',
+] 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;
+ /** 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;
+}>;
+
+/** Renders a usage's location 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 morpheme
+ * breakdown and the places the analysis is applied.
+ *
+ * Each row owns its own layout so that its detail can be nested inside it.
+ */
+function CatalogRowView({
+ row,
+ usageCountInBookLabel,
+ isSelected,
+ onUsageSelect,
+ localizedStrings,
+ analysisLanguage,
+}: 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);
+ }, []);
+
+ 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%'];
+
+ /** The row's gloss, or the placeholder standing in for an analysis that carries none. */
+ const glossLabel = row.gloss || 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();
+ const glossTooltip = useTruncationTooltip();
+
+ return (
+
+ {/*
+ 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.
+ */}
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {row.surfaceText}
+
+
+ {row.surfaceText}
+
+
+
+
+ {glossLabel}
+
+
+ {glossLabel}
+
+ {/*
+ 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.
+ */}
+
+ {row.usageCount}
+ {` ${usageCountLabel}`}
+
+
+ {row.usageCountInBook}
+ {` ${usageCountInBookLabel}`}
+
+
+
+ {isExpanded && (
+
+ {row.morphemes.length > 0 && (
+
+ {row.morphemes.map((morpheme) => (
+ // Form above gloss, as the interlinear view arranges them, so a breakdown reads the
+ // same in both places.
+
+ {morpheme.form}
+
+ {morpheme.gloss?.[analysisLanguage] ?? ''}
+
+
+ ))}
+
+ )}
+
+ {row.usages.length === 0 ? (
+
+ {localizedStrings['%interlinearizer_analysisCatalog_noUsages%']}
+
+ ) : (
+
+ {visibleUsages.map((usage) => (
+ onUsageSelect(row.analysisId, usage)}
+ size="sm"
+ variant="link"
+ >
+ {usageLabel(usage)}
+
+ ))}
+ {hiddenUsageCount > 0 && (
+ setShowsAllUsages(true)}
+ size="sm"
+ variant="link"
+ >
+ {formatReplacementString(
+ localizedStrings['%interlinearizer_analysisCatalog_showAllUsages%'],
+ { count: hiddenUsageCount },
+ )}
+
+ )}
+
+ )}
+
+ )}
+
+ );
+}
+
+/** Memoized version of {@link CatalogRowView}; use in render-stable row lists. */
+const MemoizedCatalogRowView = memo(CatalogRowView);
+export default MemoizedCatalogRowView;
diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx
index 4a1de720..b9579d3c 100644
--- a/src/components/InterlinearizerLoader.tsx
+++ b/src/components/InterlinearizerLoader.tsx
@@ -5,10 +5,16 @@ import type {
} from '@papi/core';
import papi, { logger } from '@papi/frontend';
import { useData, useLocalizedStrings, useSetting } from '@papi/frontend/react';
-import { TabToolbar } from 'platform-bible-react';
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+ TabToolbar,
+} from 'platform-bible-react';
import type { SelectMenuItemHandler } from 'platform-bible-react';
import { isPlatformError } from 'platform-bible-utils';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { ComponentProps, ReactNode, RefObject } from 'react';
import { resegmentBook } from 'parsers/papi/resegmentBook';
import useDraftProject from '../hooks/useDraftProject';
import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData';
@@ -24,15 +30,17 @@ import type { SegmentationDispatch } from './SegmentationStore';
import type { InterlinearProjectSummary } from '../types/interlinear-project-summary';
import Interlinearizer from './Interlinearizer';
import { AnalysisStoreProvider } from './AnalysisStore';
+import AnalysisCatalogPanel from './AnalysisCatalogPanel';
import ViewOptionsDropdown from './controls/ViewOptionsDropdown';
import type { PhraseMode } from '../types/phrase-mode';
import ProjectModals, { type ModalState } from './modals/ProjectModals';
import { WipeModal, type WipeScope } from './modals/WipeModal';
import ScriptureNavControls from './controls/ScriptureNavControls';
-import { InterlinearNavProvider, useInterlinearNav } from './InterlinearNavContext';
+import { InterlinearNavProvider, useInterlinearNav, type FadePhase } from './InterlinearNavContext';
import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade';
import { firstVerseNumber, segmentContainsVerse } from '../utils/verse-ref';
import { resolvedOrEmpty } from '../utils/localized-strings';
+import usePanelResizeKeys from '../hooks/usePanelResizeKeys';
/** Host-injected callback to update this WebView's definition (used to toggle the tab title). */
type UpdateWebViewDefinition = WebViewProps['updateWebViewDefinition'];
@@ -53,9 +61,87 @@ const DEFAULT_WEB_VIEW_MENU = {
*/
const BASE_TAB_TITLE = 'Interlinearizer';
+/** Props for {@link BookFadeWrapper}. */
+type BookFadeWrapperProps = Readonly<{
+ /** How far through a cross-book fade the view is. */
+ fadePhase: FadePhase;
+ /** The interlinear view, or the placeholder standing in for it. */
+ children: ReactNode;
+}>;
+
+/**
+ * The cross-book curtain: the column holding whatever the view is showing of the book, dimmed while
+ * a jump to another book is in flight.
+ *
+ * @returns An element carrying `data-testid="book-fade-wrapper"`.
+ */
+function BookFadeWrapper({ fadePhase, children }: BookFadeWrapperProps) {
+ return (
+
+ {children}
+
+ );
+}
+
/** Glyph appended to the tab title while the draft has unsaved changes. */
const UNSAVED_TAB_MARKER = ' ●';
+/** Identifies the interlinear view within the catalog group, in a {@link PanelLayout} and the DOM. */
+const VIEW_PANEL_ID = 'interlinearView';
+
+/** Identifies the catalog within its group, in a {@link PanelLayout} and the DOM. */
+const CATALOG_PANEL_ID = 'analysisCatalog';
+
+/**
+ * How much of the container the interlinear view keeps whatever the catalog is resized to. The
+ * panel sits beside the text rather than over it, so a container too narrow for both narrows the
+ * catalog rather than pushing the text off the screen.
+ */
+const MIN_VIEW_WIDTH = '240px';
+
+/** Narrowest the catalog may be resized to, below which its usage counts stop fitting. */
+const MIN_CATALOG_WIDTH = '220px';
+
+/** Widest the catalog may be resized to, past which no gloss needs the room. */
+const MAX_CATALOG_WIDTH = '800px';
+
+/**
+ * A resizable group's layout: the percentage of the group each of its panels holds, by panel id.
+ * Percentages rather than fractions because a group rescales any layout it is handed to sum to 100,
+ * so that is the unit one comes back in.
+ */
+type PanelLayout = Readonly>;
+
+/** Holds the handle a resizable group exposes for moving its panels after it has mounted. */
+type GroupHandleRef = Extract<
+ ComponentProps['groupRef'],
+ RefObject
+>;
+
+/**
+ * How the catalog group is laid out before the user has ever resized it: enough of the container
+ * for a gloss to be read beside the text without crowding it.
+ */
+const DEFAULT_CATALOG_LAYOUT: PanelLayout = { [VIEW_PANEL_ID]: 75, [CATALOG_PANEL_ID]: 25 };
+
+/**
+ * How much of the group Home and End aim the catalog at. What it settles on is whatever
+ * {@link MIN_CATALOG_WIDTH} and {@link MAX_CATALOG_WIDTH} allow, those being the real limits.
+ */
+const CATALOG_PERCENTAGE_BOUNDS = { min: 15, max: 50 };
+
/**
* Localized string keys the load/error placeholder needs. Hoisted to module scope so the reference
* passed to `useLocalizedStrings` is stable across renders; a fresh array literal each render makes
@@ -65,6 +151,7 @@ const STRING_KEYS = [
'%interlinearizer_error_load_book_heading%',
'%interlinearizer_error_process_book_heading%',
'%interlinearizer_loading%',
+ '%interlinearizer_analysisCatalog_resize%',
] as const satisfies `%${string}%`[];
/**
@@ -246,6 +333,7 @@ function InterlinearizerLoaderInner({
isLoading,
bookError,
tokenizeError,
+ writingSystem,
} = useInterlinearizerBookData({
projectId,
scrRef,
@@ -378,6 +466,22 @@ function InterlinearizerLoaderInner({
if (hasError) cancelFade();
}, [hasError, cancelFade]);
+ /**
+ * Whether the analysis catalog panel is showing. Tab-scoped rather than a project setting: two
+ * tabs on one project are routinely opened to look at different things, and a panel one of them
+ * opened has no business appearing in the other.
+ */
+ const [catalogOpen, setCatalogOpen] = useWebViewState('analysisCatalogOpen', false);
+
+ /**
+ * How the interlinear view and the catalog beside it divide the room between them, tab-scoped for
+ * the same reason the catalog's open flag is.
+ */
+ const [catalogLayout, setCatalogLayout] = useWebViewState(
+ 'analysisCatalogLayout',
+ DEFAULT_CATALOG_LAYOUT,
+ );
+
const [modal, setModal] = useState('none');
/** Whether the destructive wipe dialog (book / whole-draft scope picker) is open. */
@@ -466,6 +570,59 @@ function InterlinearizerLoaderInner({
/** Dismisses the wipe dialog, leaving the draft untouched. */
const handleWipeCancel = useCallback(() => setWipeModalOpen(false), []);
+ /** Dismisses the analysis catalog panel. */
+ const handleCatalogClose = useCallback(() => setCatalogOpen(false), [setCatalogOpen]);
+
+ /**
+ * Records a layout the group reports, keeping the stored one naming both panels. A group reports
+ * a layout over the panels mounted at the time, so a closed catalog is reported absent rather
+ * than at the width it was left at, and storing that would lose the width for the reopening.
+ */
+ const handleCatalogLayoutChanged = useCallback(
+ (layout: PanelLayout) => {
+ if (VIEW_PANEL_ID in layout && CATALOG_PANEL_ID in layout) setCatalogLayout(layout);
+ },
+ [setCatalogLayout],
+ );
+
+ /**
+ * Moves the catalog group's panels, the group reading its `defaultLayout` only as it mounts and
+ * so staying where it is for any later layout written to state alone.
+ */
+ // eslint-disable-next-line no-null/no-null
+ const catalogGroupRef: GroupHandleRef = useRef(null);
+
+ /**
+ * Moves the catalog to a percentage of the group a key press asked it be given, the view taking
+ * the rest. Storing the new width is left to the group's own report of what it settled on: a
+ * percentage the pixel limits do not allow is clamped on the way in, and storing the percentage
+ * asked for instead would record a width the catalog never took.
+ */
+ const handleCatalogPercentageChange = useCallback((percentage: number) => {
+ catalogGroupRef.current?.setLayout({
+ [VIEW_PANEL_ID]: 100 - percentage,
+ [CATALOG_PANEL_ID]: percentage,
+ });
+ }, []);
+
+ /**
+ * Restores the width the catalog was last left at as it opens. The group outlives the panel and
+ * honors `defaultLayout` only while every panel it names is mounted, so the layout held for a
+ * closed catalog is not one the group will have applied by itself.
+ */
+ const catalogLayoutRef = useRef(catalogLayout);
+ catalogLayoutRef.current = catalogLayout;
+ useEffect(() => {
+ if (catalogOpen) catalogGroupRef.current?.setLayout(catalogLayoutRef.current);
+ }, [catalogOpen]);
+
+ const catalogResizeRef = usePanelResizeKeys(
+ /* v8 ignore next -- every stored layout names the catalog, the default included */
+ catalogLayout[CATALOG_PANEL_ID] ?? DEFAULT_CATALOG_LAYOUT[CATALOG_PANEL_ID],
+ handleCatalogPercentageChange,
+ CATALOG_PERCENTAGE_BOUNDS,
+ );
+
/**
* Routes top-menu commands to the appropriate action. The project commands open their modals; the
* file commands save (or open Save As); the draft command opens the wipe dialog.
@@ -486,9 +643,11 @@ function InterlinearizerLoaderInner({
setModal('saveAs');
} else if (item.command === 'interlinearizer.wipe') {
setWipeModalOpen(true);
+ } else if (item.command === 'interlinearizer.openAnalysisCatalog') {
+ setCatalogOpen(true);
}
},
- [activeProject, handleSave],
+ [activeProject, handleSave, setCatalogOpen],
);
/**
@@ -613,29 +772,22 @@ function InterlinearizerLoaderInner({
}}
/>
-
+
{isDraftLoading ? (
// The store below waits for the draft: it seeds on mount alone, and the draft version
// that remounts it does not bump when the load completes. Nothing is lost by waiting —
// while the draft loads there is only ever a placeholder or an error panel to show.
- loadingOrErrorPanel
+
{loadingOrErrorPanel}
) : (
- // The store's lifetime is the draft's, not the loaded book's — it holds every book. Keyed
- // on the draft version because the seed is not reactive, so a wholesale replacement (New
- // / Open / Wipe) reseeds by remounting. Wrapping the loading and error branches too keeps
- // it alive across the gap while the next book's USJ is in flight.
+ // The store's lifetime is the draft's, not the loaded book's — it holds every book.
+ // Keyed on the draft version because the seed is not reactive, so a wholesale replacement
+ // (New / Open / Wipe) reseeds by remounting. Wrapping the loading and error branches too
+ // keeps it alive across the gap while the next book's USJ is in flight.
+ //
+ // Declared above the cross-book curtain, not inside it, so the catalog panel can read the
+ // store without being dimmed by it: a jump to a usage in another book fades the view it
+ // navigates, and fading the list the jump was made from along with it would blank the
+ // panel at precisely the moment it is being used.
- {bookArea}
+ {/*
+ * The group stays mounted whether or not the catalog is open, only the catalog's own
+ * panel coming and going, so that the view keeps one place in the tree. A view that
+ * changed place here would remount, losing what the reader was in the middle of: where
+ * the segment list was scrolled to, a gloss typed but not yet committed, an open
+ * breakdown editor.
+ */}
+
+
+ {bookArea}
+
+ {catalogOpen && (
+ <>
+
+
+
+
+ >
+ )}
+
)}
diff --git a/src/hooks/useInterlinearizerBookData.ts b/src/hooks/useInterlinearizerBookData.ts
index 75ef0b1a..19212efa 100644
--- a/src/hooks/useInterlinearizerBookData.ts
+++ b/src/hooks/useInterlinearizerBookData.ts
@@ -28,6 +28,12 @@ export interface UseInterlinearizerBookDataResult {
bookError: string | undefined;
/** Error thrown by {@link extractBookFromUsj} or {@link tokenizeBook}; `undefined` on success. */
tokenizeError: { message: string; raw: unknown } | undefined;
+ /**
+ * BCP 47 tag the book's text was tokenized under, `'und'` when the project declares none. Carries
+ * a tag whether or not `book` loaded, so source text can be collated or rendered before the text
+ * itself arrives.
+ */
+ writingSystem: string;
}
/**
@@ -103,5 +109,5 @@ export default function useInterlinearizerBookData({
bookError = `No USJ book available for ${scrRef.book} in project ${projectId}`;
}
- return { book, isLoading, bookError, tokenizeError };
+ return { book, isLoading, bookError, tokenizeError, writingSystem: writingSystemTag };
}
diff --git a/src/hooks/usePanelResizeKeys.ts b/src/hooks/usePanelResizeKeys.ts
new file mode 100644
index 00000000..c1194f55
--- /dev/null
+++ b/src/hooks/usePanelResizeKeys.ts
@@ -0,0 +1,104 @@
+import { readDirection } from 'platform-bible-react/experimental';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+/**
+ * How far one arrow-key press resizes the panel, as a percentage of the group. Matches the step the
+ * platform handle takes, so an arrow moves the panel equally far whichever of the two answers it.
+ */
+const KEYBOARD_RESIZE_STEP = 5;
+
+/**
+ * How far one jump-key press resizes the panel. Farther than the widest panel, so a press lands
+ * against whichever bound it points at rather than part way to it.
+ */
+const KEYBOARD_JUMP_STEP = 100;
+
+/**
+ * Which way along the screen the handle travels to widen the panel: `-1` toward the screen's left,
+ * `1` toward its right. Read afresh on each press, so a panel that outlives a change of interface
+ * language resizes the way it is currently pointing.
+ */
+function widenTravel(): number {
+ return readDirection() === 'rtl' ? 1 : -1;
+}
+
+/** Which way along the screen a key moves the handle, `0` for a key that moves it nowhere. */
+function keyTravel(key: string): { travel: number; step: number } {
+ if (key === 'ArrowLeft') return { travel: -1, step: KEYBOARD_RESIZE_STEP };
+ if (key === 'ArrowRight') return { travel: 1, step: KEYBOARD_RESIZE_STEP };
+ // Jump keys travel the way the arrow beside them points: `Home` toward the screen's left, `End`
+ // toward its right.
+ if (key === 'Home') return { travel: -1, step: KEYBOARD_JUMP_STEP };
+ if (key === 'End') return { travel: 1, step: KEYBOARD_JUMP_STEP };
+ return { travel: 0, step: 0 };
+}
+
+/**
+ * Resizes a panel by arrow or jump key in a right-to-left interface, where the platform handle
+ * would otherwise move it the wrong way: the handle steps by a signed amount that never consults
+ * the interface direction, so an arrow pointing at the panel's own edge widens it instead of
+ * narrowing it, and `Home`/`End` land against the bound opposite the arrow beside them. Every other
+ * key, and every key in a left-to-right interface, is left to the handle.
+ *
+ * Returns a ref rather than a handler because the platform binds its own key handler to the
+ * handle's element directly, and only a listener on that element in the capture phase runs early
+ * enough to claim a press before it. A press claimed too late is stepped twice, once by each.
+ *
+ * Sizes are percentages of the group the panel is laid out in, `25` being a quarter of it, matching
+ * the unit a platform group lays out in and hands back.
+ *
+ * @param percentage - Percentage the panel currently holds, which a press resizes from.
+ * @param onPercentageChange - Records a percentage a press asked for. Not called for a press that
+ * would leave the panel where it already is.
+ * @param bounds - Narrowest and widest percentages a press may reach.
+ * @returns A ref for the resize handle's element.
+ */
+export default function usePanelResizeKeys(
+ percentage: number,
+ onPercentageChange: (percentage: number) => void,
+ bounds: { min: number; max: number },
+): (element: HTMLElement | null) => void {
+ const { min, max } = bounds;
+
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ // The keys below are recognized by name alone, so a modified press — Alt+Arrow, which some
+ // hosts navigate back on — would both resize the panel and swallow the host's shortcut.
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
+
+ const { travel, step } = keyTravel(event.key);
+ // Left alone in a left-to-right interface, where the platform handle already reads these
+ // keys the way the panel is pointing; stepping here as well would move it twice.
+ if (travel === 0 || widenTravel() !== 1) return;
+
+ // Claims the press before the platform's own handler sees it, that handler starting by
+ // returning on an event already defaulted.
+ event.preventDefault();
+
+ const next = Math.min(max, Math.max(min, percentage + travel * widenTravel() * step));
+ // A key pressed at the end of the range it moves toward — an arrow held down there repeating,
+ // or a jump key aimed at it — would otherwise put an unchanged layout through the store.
+ if (next !== percentage) onPercentageChange(next);
+ },
+ [percentage, onPercentageChange, min, max],
+ );
+
+ // Read through a ref so a press runs the current handler without the listener being rebound for
+ // every resize, which would rebind it under a held-down arrow.
+ const handlerRef = useRef(handleKeyDown);
+ handlerRef.current = handleKeyDown;
+
+ // Held in state rather than a ref so that attaching the listener re-runs once the handle mounts;
+ // a ref filled in during commit would change without rendering, leaving the effect never re-run.
+ // eslint-disable-next-line no-null/no-null
+ const [element, setElement] = useState
(null);
+
+ useEffect(() => {
+ if (!element) return undefined;
+ const listener = (event: KeyboardEvent) => handlerRef.current(event);
+ element.addEventListener('keydown', listener, true);
+ return () => element.removeEventListener('keydown', listener, true);
+ }, [element]);
+
+ return setElement;
+}
diff --git a/src/main.ts b/src/main.ts
index 16fd3c8e..4a2f1e29 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -766,6 +766,19 @@ export async function activate(context: ExecutionActivationContext): Promise {},
+ {
+ method: {
+ summary: 'Open the analysis catalog panel in the Interlinearizer WebView',
+ params: [],
+ result: { name: 'return value', summary: 'void', schema: { type: 'null' } },
+ },
+ },
+ );
+
const saveCommandRegistration = await papi.commands.registerCommand(
'interlinearizer.save',
// Handled entirely in the WebView; backend registration makes the command known to the platform.
@@ -838,6 +851,7 @@ export async function activate(context: ExecutionActivationContext): Promise Promise;
+ /**
+ * Opens the analysis catalog panel in the Interlinearizer WebView, listing every analysis the
+ * draft records with its usage counts and locations. The backend registers this command to make
+ * it visible to the platform menu system; all logic executes in the WebView.
+ */
+ 'interlinearizer.openAnalysisCatalog': () => Promise;
+
/**
* Loads the interlinearizer project with the given UUID, including its full `TextAnalysis`. The
* WebView calls this when the active project changes to load the stored analysis.
diff --git a/src/utils/analysis-query.ts b/src/utils/analysis-query.ts
index e523611d..d80d3d82 100644
--- a/src/utils/analysis-query.ts
+++ b/src/utils/analysis-query.ts
@@ -6,6 +6,7 @@ import type {
TokenAnalysis,
TokenAnalysisLink,
} from 'interlinearizer';
+import type { Collator } from 'platform-bible-utils';
import { bookOfRef } from './analysis-book';
import { foldForSearch } from './search-fold';
import { firstVerseNumber } from './verse-ref';
@@ -84,9 +85,9 @@ export interface CatalogQuery {
sort: CatalogSort;
filters: CatalogFilters;
/** Collates surface forms, so ordering follows the source language rather than code points. */
- surfaceCollator: Intl.Collator;
+ surfaceCollator: Collator;
/** Collates glosses, so ordering follows the analysis language rather than code points. */
- glossCollator: Intl.Collator;
+ glossCollator: Collator;
}
/**
@@ -259,7 +260,7 @@ function compareFirstUsage(a: CatalogRow, b: CatalogRow): number {
* Orders two rows by gloss, an analysis with none in the scope's language coming after every one
* that has one.
*/
-function compareGloss(a: CatalogRow, b: CatalogRow, glossCollator: Intl.Collator): number {
+function compareGloss(a: CatalogRow, b: CatalogRow, glossCollator: Collator): number {
if (!a.gloss) return b.gloss ? 1 : 0;
if (!b.gloss) return -1;
return glossCollator.compare(a.gloss, b.gloss);
diff --git a/src/utils/language-tags.ts b/src/utils/language-tags.ts
index 8d929ea9..460e8e2e 100644
--- a/src/utils/language-tags.ts
+++ b/src/utils/language-tags.ts
@@ -1,3 +1,5 @@
+import { Collator } from 'platform-bible-utils';
+
/**
* Parses a comma-separated analysis-language field into BCP 47 tags. The single source of this
* parse, so no field can interpret the same input differently.
@@ -13,3 +15,18 @@ export function parseLanguageTags(input: string): string[] {
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
}
+
+/**
+ * A collator for `tag`, falling back to the host's default collation when the tag is unusable.
+ *
+ * Language tags reach this as free text — nothing checks them for BCP 47 structure on the way in —
+ * and constructing a collator for an unparsable tag throws, so it has to degrade to some ordering
+ * rather than take the view down.
+ */
+export function collatorForTag(tag: string): Collator {
+ try {
+ return new Collator(tag);
+ } catch {
+ return new Collator();
+ }
+}