@krapwoo/ds-viewer 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/dist/cli/doctor.js +182 -3
- package/dist/cli/staticPage.js +129 -0
- package/dist/native/catalog/catalogNavigation.js +62 -3
- package/dist/native/catalog/comparison.js +14 -3
- package/dist/native/catalog/pageApi.js +408 -0
- package/dist/native/catalog/tokens.js +7 -0
- package/native/catalog/CatalogShell.tsx +206 -25
- package/native/catalog/CatalogSidebar.tsx +57 -3
- package/native/catalog/FamilyPage.tsx +1188 -0
- package/native/catalog/SectionBlock.tsx +47 -11
- package/native/catalog/catalogNavigation.ts +89 -3
- package/native/catalog/comparison.ts +17 -6
- package/native/catalog/familyModel.ts +192 -0
- package/native/catalog/index.ts +5 -2
- package/native/catalog/overlayViewport.ts +30 -10
- package/native/catalog/pageApi.ts +357 -15
- package/native/catalog/tokens.ts +7 -0
- package/native/catalog/types.ts +106 -0
- package/package.json +1 -1
|
@@ -4,8 +4,21 @@ import type { ImageSourcePropType } from 'react-native';
|
|
|
4
4
|
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_MAX_CONTENT_WIDTH, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
|
|
5
5
|
import { CatalogSidebar } from './CatalogSidebar';
|
|
6
6
|
import { SectionBlock } from './SectionBlock';
|
|
7
|
+
import { FamilyPage } from './FamilyPage';
|
|
7
8
|
import { UpdatePanel } from './UpdatePanel';
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
aliasTargets,
|
|
11
|
+
DS_VIEWER_SECRET_HEADER,
|
|
12
|
+
footerLink,
|
|
13
|
+
groupLabelFor,
|
|
14
|
+
hashForId,
|
|
15
|
+
neighbors,
|
|
16
|
+
orderedIds,
|
|
17
|
+
resolveRoute,
|
|
18
|
+
shouldShowMajorBanner,
|
|
19
|
+
UPDATE_PAGE_ID,
|
|
20
|
+
type MemberSearchEntry,
|
|
21
|
+
} from './catalogNavigation';
|
|
9
22
|
import { findOverlayNode, overlayAddressFromSearch } from './overlayViewport';
|
|
10
23
|
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
11
24
|
import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus } from './types';
|
|
@@ -88,6 +101,41 @@ function MajorBanner({ update, onReviewUpdate, onDismiss, keepDocumentUrl = fals
|
|
|
88
101
|
);
|
|
89
102
|
}
|
|
90
103
|
|
|
104
|
+
/** Spec §5.3 "Unknown or malformed address": shown instead of silently falling back to the first
|
|
105
|
+
* page, for every page (not only family pages). Focuses its own title on mount — unlike a normal
|
|
106
|
+
* page change, this can be the very first thing a fresh load shows, so it can't wait for the
|
|
107
|
+
* shared "focus only when the user navigated" logic the rest of the shell uses. */
|
|
108
|
+
function NotFoundPage({ raw, firstHref, onOpenFirst }: { raw: string; firstHref?: string; onOpenFirst: () => void }) {
|
|
109
|
+
const ref = useRef<View>(null);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
focusElement(ref.current);
|
|
112
|
+
}, []);
|
|
113
|
+
return (
|
|
114
|
+
<View>
|
|
115
|
+
<View ref={ref} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={notFoundStyles.headingTarget}>
|
|
116
|
+
<Text style={notFoundStyles.title}>Page not found</Text>
|
|
117
|
+
</View>
|
|
118
|
+
<Text style={notFoundStyles.body}>{`There's no catalog page at #${raw}.`}</Text>
|
|
119
|
+
<Pressable
|
|
120
|
+
onPress={onOpenFirst}
|
|
121
|
+
accessibilityRole="link"
|
|
122
|
+
{...(firstHref ? ({ href: firstHref } as Record<string, unknown>) : null)}
|
|
123
|
+
style={notFoundStyles.link}
|
|
124
|
+
>
|
|
125
|
+
<Text style={notFoundStyles.linkText}>Open the first page</Text>
|
|
126
|
+
</Pressable>
|
|
127
|
+
</View>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const notFoundStyles = StyleSheet.create({
|
|
132
|
+
headingTarget: { alignSelf: 'flex-start', marginBottom: 6 },
|
|
133
|
+
title: { ...CATALOG_TEXT.pageTitle, color: CATALOG_COLOR.text },
|
|
134
|
+
body: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, marginBottom: CATALOG_SPACE.lg },
|
|
135
|
+
link: { alignSelf: 'flex-start', minHeight: CATALOG_LAYOUT.controlSize, justifyContent: 'center' },
|
|
136
|
+
linkText: { ...CATALOG_TEXT.bodyStrong, color: CATALOG_COLOR.accent },
|
|
137
|
+
});
|
|
138
|
+
|
|
91
139
|
const bannerStyles = StyleSheet.create({
|
|
92
140
|
banner: {
|
|
93
141
|
flexDirection: 'row',
|
|
@@ -174,9 +222,64 @@ export function CatalogShell<TId extends string>({
|
|
|
174
222
|
const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
|
|
175
223
|
const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
|
|
176
224
|
const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys())), [groups, sectionsById]);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
)
|
|
225
|
+
// Spec §5.3: every family page's own member ids/aliases, resolved to their page (and member, for
|
|
226
|
+
// anything other than the whole) — used both for an old/alias/member address (`resolveRoute`)
|
|
227
|
+
// and for the sidebar's member search (`memberSearchEntries` below).
|
|
228
|
+
const aliasMap = useMemo(() => aliasTargets(sections), [sections]);
|
|
229
|
+
// V2d item 3: every real catalog address a family member's `composedOf` could resolve to,
|
|
230
|
+
// outside its own family — every other page's own id, plus every member id/alias `aliasMap`
|
|
231
|
+
// already resolves (`FamilyPage` itself always adds its own family's member ids on top of this).
|
|
232
|
+
const knownAddressIds = useMemo(() => new Set<string>([...sectionsById.keys(), ...aliasMap.keys()]), [sectionsById, aliasMap]);
|
|
233
|
+
const memberIndex = useMemo(() => {
|
|
234
|
+
const map = new Map<string, string[]>();
|
|
235
|
+
for (const section of sections) {
|
|
236
|
+
if (!section.family) continue;
|
|
237
|
+
const ids: string[] = [];
|
|
238
|
+
for (const member of section.family.members) {
|
|
239
|
+
ids.push(member.id, ...(member.aliases ?? []));
|
|
240
|
+
}
|
|
241
|
+
map.set(section.id, ids);
|
|
242
|
+
}
|
|
243
|
+
return map;
|
|
244
|
+
}, [sections]);
|
|
245
|
+
const memberSearchEntries = useMemo(() => {
|
|
246
|
+
const entries: MemberSearchEntry<TId>[] = [];
|
|
247
|
+
for (const section of sections) {
|
|
248
|
+
if (!section.family) continue;
|
|
249
|
+
for (const member of section.family.members) {
|
|
250
|
+
if (member.id === section.family.whole) continue;
|
|
251
|
+
entries.push({ pageId: section.id, memberId: member.id, aliases: member.aliases, familyTitle: section.title ?? section.id });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return entries;
|
|
255
|
+
}, [sections]);
|
|
256
|
+
|
|
257
|
+
const routeFromHash = useCallback((hash: string) => resolveRoute(hash, order, aliasMap), [order, aliasMap]);
|
|
258
|
+
|
|
259
|
+
const [active, setActive] = useState<ShellPageId<TId> | undefined>(() => {
|
|
260
|
+
if (embedded) return initialPage ?? order[0];
|
|
261
|
+
if (!isWeb()) return order[0];
|
|
262
|
+
const route = routeFromHash(window.location.hash);
|
|
263
|
+
if (route.kind === 'empty') return order[0];
|
|
264
|
+
if (route.kind === 'update') return UPDATE_PAGE_ID;
|
|
265
|
+
if (route.kind === 'page') return route.id;
|
|
266
|
+
if (route.kind === 'member') return route.pageId;
|
|
267
|
+
return undefined; // notFound
|
|
268
|
+
});
|
|
269
|
+
const [pendingMemberId, setPendingMemberId] = useState<string | undefined>(() => {
|
|
270
|
+
if (embedded || !isWeb()) return undefined;
|
|
271
|
+
const route = routeFromHash(window.location.hash);
|
|
272
|
+
return route.kind === 'member' ? route.memberId : undefined;
|
|
273
|
+
});
|
|
274
|
+
// V2c item 11: bumped on every member activation, even a repeat one, so `FamilyPage`'s own
|
|
275
|
+
// one-shot jump (`shouldRunMemberJump`) counts a re-selected member address as fresh — a plain
|
|
276
|
+
// `pendingMemberId` state update bails out (no re-render) when the string value is unchanged.
|
|
277
|
+
const [pendingMemberNonce, setPendingMemberNonce] = useState(0);
|
|
278
|
+
const [notFoundRaw, setNotFoundRaw] = useState<string | undefined>(() => {
|
|
279
|
+
if (embedded || !isWeb()) return undefined;
|
|
280
|
+
const route = routeFromHash(window.location.hash);
|
|
281
|
+
return route.kind === 'notFound' ? route.raw : undefined;
|
|
282
|
+
});
|
|
180
283
|
|
|
181
284
|
const scrollRef = useRef<ScrollView>(null);
|
|
182
285
|
const headingRef = useRef<View>(null);
|
|
@@ -185,36 +288,78 @@ export function CatalogShell<TId extends string>({
|
|
|
185
288
|
const pushHistoryNext = useRef(false);
|
|
186
289
|
const focusHeadingNext = useRef(false);
|
|
187
290
|
|
|
188
|
-
const select = useCallback((id: ShellPageId<TId
|
|
291
|
+
const select = useCallback((id: ShellPageId<TId>, memberId?: string) => {
|
|
292
|
+
setNotFoundRaw(undefined);
|
|
189
293
|
if (id === activeRef.current) {
|
|
190
|
-
|
|
294
|
+
if (memberId) {
|
|
295
|
+
pushHistoryNext.current = true;
|
|
296
|
+
setPendingMemberId(memberId);
|
|
297
|
+
setPendingMemberNonce((n) => n + 1);
|
|
298
|
+
} else {
|
|
299
|
+
setPendingMemberId(undefined);
|
|
300
|
+
focusElement(headingRef.current);
|
|
301
|
+
}
|
|
191
302
|
return;
|
|
192
303
|
}
|
|
193
304
|
pushHistoryNext.current = true;
|
|
194
|
-
focusHeadingNext.current =
|
|
305
|
+
focusHeadingNext.current = !memberId;
|
|
306
|
+
setPendingMemberId(memberId);
|
|
307
|
+
if (memberId) setPendingMemberNonce((n) => n + 1);
|
|
195
308
|
setActive(id);
|
|
196
309
|
}, []);
|
|
197
310
|
|
|
198
|
-
// Back/forward
|
|
311
|
+
// Back/forward, manual fragment edits, and our own hash assignments below (which also fire
|
|
312
|
+
// `hashchange`) all funnel through here.
|
|
199
313
|
useEffect(() => {
|
|
200
314
|
if (!routesByUrl) return;
|
|
201
315
|
const onHashChange = () => {
|
|
202
|
-
const
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
316
|
+
const route = routeFromHash(window.location.hash);
|
|
317
|
+
if (route.kind === 'notFound') {
|
|
318
|
+
setNotFoundRaw(route.raw);
|
|
319
|
+
setPendingMemberId(undefined);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
setNotFoundRaw(undefined);
|
|
323
|
+
if (route.kind === 'empty') {
|
|
324
|
+
setPendingMemberId(undefined);
|
|
325
|
+
if (order[0] !== undefined && order[0] !== activeRef.current) {
|
|
326
|
+
focusHeadingNext.current = true;
|
|
327
|
+
setActive(order[0]);
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (route.kind === 'update') {
|
|
332
|
+
setPendingMemberId(undefined);
|
|
333
|
+
if (UPDATE_PAGE_ID !== activeRef.current) {
|
|
334
|
+
focusHeadingNext.current = true;
|
|
335
|
+
setActive(UPDATE_PAGE_ID);
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (route.kind === 'page') {
|
|
340
|
+
setPendingMemberId(undefined);
|
|
341
|
+
if (route.id !== activeRef.current) {
|
|
342
|
+
focusHeadingNext.current = true;
|
|
343
|
+
setActive(route.id);
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
// member: scroll/open/focus is the member itself (FamilyPage's own effect), never the page
|
|
348
|
+
// heading — even when this also switches the active page.
|
|
349
|
+
setPendingMemberId(route.memberId);
|
|
350
|
+
setPendingMemberNonce((n) => n + 1);
|
|
351
|
+
if (route.pageId !== activeRef.current) {
|
|
352
|
+
focusHeadingNext.current = false;
|
|
353
|
+
setActive(route.pageId);
|
|
210
354
|
}
|
|
211
355
|
};
|
|
212
356
|
window.addEventListener('hashchange', onHashChange);
|
|
213
357
|
return () => window.removeEventListener('hashchange', onHashChange);
|
|
214
|
-
}, [order, routesByUrl]);
|
|
358
|
+
}, [order, routesByUrl, routeFromHash]);
|
|
215
359
|
|
|
216
|
-
// After every page change: sync the fragment, return to the top
|
|
217
|
-
//
|
|
360
|
+
// After every page or member change: sync the fragment, return to the top (unless a member is
|
|
361
|
+
// addressed — FamilyPage scrolls to that instead), and move focus when the user navigated to a
|
|
362
|
+
// new page (never on first load, and never for a member address, which focuses the member).
|
|
218
363
|
useEffect(() => {
|
|
219
364
|
if (active === undefined) return;
|
|
220
365
|
const push = pushHistoryNext.current;
|
|
@@ -222,15 +367,17 @@ export function CatalogShell<TId extends string>({
|
|
|
222
367
|
pushHistoryNext.current = false;
|
|
223
368
|
focusHeadingNext.current = false;
|
|
224
369
|
if (routesByUrl) {
|
|
225
|
-
const expected = hashForId(active);
|
|
370
|
+
const expected = hashForId(pendingMemberId ?? active);
|
|
226
371
|
if (window.location.hash !== expected) {
|
|
227
372
|
if (push) window.location.hash = expected;
|
|
228
373
|
else window.history.replaceState(null, '', expected);
|
|
229
374
|
}
|
|
230
375
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
376
|
+
if (!pendingMemberId) {
|
|
377
|
+
resetMainScroll(scrollRef.current);
|
|
378
|
+
if (focus) focusElement(headingRef.current);
|
|
379
|
+
}
|
|
380
|
+
}, [active, pendingMemberId, routesByUrl]);
|
|
234
381
|
|
|
235
382
|
const activeDef = active !== undefined && active !== UPDATE_PAGE_ID ? sectionsById.get(active) : undefined;
|
|
236
383
|
|
|
@@ -284,9 +431,24 @@ export function CatalogShell<TId extends string>({
|
|
|
284
431
|
|
|
285
432
|
return (
|
|
286
433
|
<View style={styles.root}>
|
|
287
|
-
<CatalogSidebar
|
|
434
|
+
<CatalogSidebar
|
|
435
|
+
logo={appName}
|
|
436
|
+
logoImageSource={logoImageSource}
|
|
437
|
+
caption={title}
|
|
438
|
+
groups={groups}
|
|
439
|
+
labels={labels}
|
|
440
|
+
memberIndex={memberIndex}
|
|
441
|
+
memberSearchEntries={memberSearchEntries}
|
|
442
|
+
active={active}
|
|
443
|
+
onPress={select}
|
|
444
|
+
onSelectMember={(pageId, memberId) => select(pageId, memberId)}
|
|
445
|
+
footer={footer}
|
|
446
|
+
keepDocumentUrl={embedded}
|
|
447
|
+
/>
|
|
288
448
|
<ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
|
|
289
|
-
{
|
|
449
|
+
{notFoundRaw !== undefined ? (
|
|
450
|
+
<NotFoundPage raw={notFoundRaw} firstHref={order[0] !== undefined ? hashForId(order[0]) : undefined} onOpenFirst={() => order[0] !== undefined && select(order[0])} />
|
|
451
|
+
) : active === UPDATE_PAGE_ID ? (
|
|
290
452
|
<UpdatePanel
|
|
291
453
|
update={effectiveUpdate}
|
|
292
454
|
versionStatus={liveStatus}
|
|
@@ -295,6 +457,25 @@ export function CatalogShell<TId extends string>({
|
|
|
295
457
|
appName={appName}
|
|
296
458
|
headingRef={headingRef}
|
|
297
459
|
/>
|
|
460
|
+
) : activeDef?.family ? (
|
|
461
|
+
<FamilyPage
|
|
462
|
+
key={activeDef.id}
|
|
463
|
+
def={activeDef}
|
|
464
|
+
groupLabel={groupLabelFor(groups, activeDef.id)}
|
|
465
|
+
breadcrumbRoot={appName}
|
|
466
|
+
banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} keepDocumentUrl={embedded} /> : undefined}
|
|
467
|
+
pager={{
|
|
468
|
+
previousId: neighbors(order, activeDef.id).previous,
|
|
469
|
+
nextId: neighbors(order, activeDef.id).next,
|
|
470
|
+
onNavigate: select,
|
|
471
|
+
labelOf: (id) => labels.get(id) ?? id,
|
|
472
|
+
}}
|
|
473
|
+
headingRef={headingRef}
|
|
474
|
+
defaultPreviewWidths={defaultPreviewWidths}
|
|
475
|
+
focusMemberId={pendingMemberId}
|
|
476
|
+
focusNonce={pendingMemberNonce}
|
|
477
|
+
knownAddressIds={knownAddressIds}
|
|
478
|
+
/>
|
|
298
479
|
) : activeDef ? (
|
|
299
480
|
<SectionBlock
|
|
300
481
|
key={activeDef.id}
|
|
@@ -3,7 +3,7 @@ import { View, Text, ScrollView, Pressable, StyleSheet, Image } from 'react-nati
|
|
|
3
3
|
import type { ImageSourcePropType, LayoutChangeEvent, NativeSyntheticEvent, ImageLoadEventData } from 'react-native';
|
|
4
4
|
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
|
|
5
5
|
import { logoLayout } from './logoLayout';
|
|
6
|
-
import { filterGroups, hashForId, UPDATE_PAGE_ID } from './catalogNavigation';
|
|
6
|
+
import { filterGroups, hashForId, searchResults, UPDATE_PAGE_ID, type MemberSearchEntry } from './catalogNavigation';
|
|
7
7
|
import type { NavGroup } from './types';
|
|
8
8
|
import { CatalogSearchInput } from './CatalogSearchInput';
|
|
9
9
|
|
|
@@ -52,6 +52,29 @@ function NavItem<TId extends string>({ id, label, active, onPress, keepDocumentU
|
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** One member search hit (spec §5.3 "Search"): `<memberId> — in <familyTitle>`, opening that
|
|
56
|
+
* member's address. Shares the plain nav-row visual `NavItem` uses ("Use the existing row
|
|
57
|
+
* styles"); `href` is the member's own address so Enter activates it the same way every other
|
|
58
|
+
* link in the sidebar does. */
|
|
59
|
+
function MemberResultItem({ label, familyTitle, href, onPress, keepDocumentUrl = false }: { label: string; familyTitle: string; href: string; onPress: () => void; keepDocumentUrl?: boolean }) {
|
|
60
|
+
const [hovered, setHovered] = useState(false);
|
|
61
|
+
const [focused, setFocused] = useState(false);
|
|
62
|
+
return (
|
|
63
|
+
<Pressable
|
|
64
|
+
onPress={pressHandler(onPress, keepDocumentUrl)}
|
|
65
|
+
onHoverIn={() => setHovered(true)}
|
|
66
|
+
onHoverOut={() => setHovered(false)}
|
|
67
|
+
onFocus={() => setFocused(true)}
|
|
68
|
+
onBlur={() => setFocused(false)}
|
|
69
|
+
accessibilityRole="link"
|
|
70
|
+
{...({ href } as Record<string, unknown>)}
|
|
71
|
+
style={({ pressed }) => [styles.item, (pressed || hovered) && styles.itemHover, focused && styles.focusRing]}
|
|
72
|
+
>
|
|
73
|
+
<Text style={styles.label}>{`${label} — in ${familyTitle}`}</Text>
|
|
74
|
+
</Pressable>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
function FooterLink({ label, kind, active, onPress, keepDocumentUrl = false }: { label: string; kind: 'update' | 'version'; active: boolean; onPress: () => void; keepDocumentUrl?: boolean }) {
|
|
56
79
|
const [hovered, setHovered] = useState(false);
|
|
57
80
|
const [focused, setFocused] = useState(false);
|
|
@@ -90,8 +113,11 @@ export function CatalogSidebar<TId extends string>({
|
|
|
90
113
|
caption,
|
|
91
114
|
groups,
|
|
92
115
|
labels,
|
|
116
|
+
memberIndex,
|
|
117
|
+
memberSearchEntries,
|
|
93
118
|
active,
|
|
94
119
|
onPress,
|
|
120
|
+
onSelectMember,
|
|
95
121
|
footer,
|
|
96
122
|
keepDocumentUrl = false,
|
|
97
123
|
}: {
|
|
@@ -107,8 +133,17 @@ export function CatalogSidebar<TId extends string>({
|
|
|
107
133
|
groups: NavGroup<TId>[];
|
|
108
134
|
/** Display titles by page id (`SectionDef.title`); pages without one show their id. */
|
|
109
135
|
labels?: ReadonlyMap<string, string>;
|
|
136
|
+
/** Every family page's own member ids/aliases, by page id (spec §5.3 "Search") — a page also
|
|
137
|
+
* matches the filter on any of these, not just its own id/title. */
|
|
138
|
+
memberIndex?: ReadonlyMap<string, readonly string[]>;
|
|
139
|
+
/** Every family member search candidate across the catalog (spec §5.3 "Search") — shown as its
|
|
140
|
+
* own "Members" group of hits, below the filtered pages, while the query is non-empty. */
|
|
141
|
+
memberSearchEntries?: readonly MemberSearchEntry<TId>[];
|
|
110
142
|
active: string | undefined;
|
|
111
143
|
onPress: (id: TId) => void;
|
|
144
|
+
/** Opens a member hit's address: activates its family page, then scrolls/opens/focuses the
|
|
145
|
+
* member itself (`CatalogShell`). */
|
|
146
|
+
onSelectMember?: (pageId: TId, memberId: string) => void;
|
|
112
147
|
/** The link below the nav list that opens the update page: the installed version (`'version'`),
|
|
113
148
|
* or the update notice with a dot (`'update'`, design §5's footer line). `active` is true while
|
|
114
149
|
* the update page itself is open — it gets the same active styling a selected nav item would. */
|
|
@@ -128,7 +163,8 @@ export function CatalogSidebar<TId extends string>({
|
|
|
128
163
|
const [loadedAspect, setLoadedAspect] = useState<number | undefined>(undefined);
|
|
129
164
|
const [logoFailed, setLogoFailed] = useState(false);
|
|
130
165
|
const [nameWrapped, setNameWrapped] = useState(false);
|
|
131
|
-
const filtered = filterGroups(groups, query, labels);
|
|
166
|
+
const filtered = filterGroups(groups, query, labels, memberIndex);
|
|
167
|
+
const memberHits = memberSearchEntries ? searchResults(memberSearchEntries, query) : [];
|
|
132
168
|
|
|
133
169
|
const logoAspect = staticAspect ?? loadedAspect;
|
|
134
170
|
const attemptingLogo = Boolean(logoImageSource) && !logoFailed;
|
|
@@ -202,7 +238,10 @@ export function CatalogSidebar<TId extends string>({
|
|
|
202
238
|
|
|
203
239
|
<CatalogSearchInput value={query} onChangeText={setQuery} placeholder="Filter components…" />
|
|
204
240
|
|
|
205
|
-
{filtered.length === 0 && <Text style={styles.empty}>No matches</Text>}
|
|
241
|
+
{filtered.length === 0 && memberHits.length === 0 && <Text style={styles.empty}>No matches</Text>}
|
|
242
|
+
{/* V2c item 10: pages first, member hits below them — matching `memberSearchEntries`'s own
|
|
243
|
+
doc comment ("below the filtered pages") and the spec's "the sidebar lists family pages
|
|
244
|
+
only, never members" emphasis. */}
|
|
206
245
|
{filtered.map((group) => (
|
|
207
246
|
<View key={group.label}>
|
|
208
247
|
<Text style={styles.groupLabel}>{group.label}</Text>
|
|
@@ -211,6 +250,21 @@ export function CatalogSidebar<TId extends string>({
|
|
|
211
250
|
))}
|
|
212
251
|
</View>
|
|
213
252
|
))}
|
|
253
|
+
{memberHits.length > 0 && (
|
|
254
|
+
<View>
|
|
255
|
+
<Text style={styles.groupLabel}>Members</Text>
|
|
256
|
+
{memberHits.map((hit) => (
|
|
257
|
+
<MemberResultItem
|
|
258
|
+
key={`${hit.pageId}:${hit.memberId}`}
|
|
259
|
+
label={hit.label}
|
|
260
|
+
familyTitle={hit.familyTitle}
|
|
261
|
+
href={hashForId(hit.memberId)}
|
|
262
|
+
onPress={() => onSelectMember?.(hit.pageId, hit.memberId)}
|
|
263
|
+
keepDocumentUrl={keepDocumentUrl}
|
|
264
|
+
/>
|
|
265
|
+
))}
|
|
266
|
+
</View>
|
|
267
|
+
)}
|
|
214
268
|
</ScrollView>
|
|
215
269
|
{footer && (
|
|
216
270
|
<View style={styles.footerContainer}>
|