@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.
@@ -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 { DS_VIEWER_SECRET_HEADER, footerLink, groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner, UPDATE_PAGE_ID } from './catalogNavigation';
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
- const [active, setActive] = useState<ShellPageId<TId> | undefined>(() =>
178
- embedded ? initialPage ?? order[0] : isWeb() ? resolveActiveFromHash(window.location.hash, order) : order[0],
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
- focusElement(headingRef.current);
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 = true;
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 and manual fragment edits.
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 id = resolveActiveFromHash(window.location.hash, order);
203
- if (id === undefined) return;
204
- if (id !== activeRef.current) {
205
- focusHeadingNext.current = true;
206
- setActive(id);
207
- } else if (window.location.hash !== hashForId(id)) {
208
- // An unknown fragment that falls back to the page already open: just correct the URL.
209
- window.history.replaceState(null, '', hashForId(id));
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, and move focus when the user
217
- // navigated (never on first load).
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
- resetMainScroll(scrollRef.current);
232
- if (focus) focusElement(headingRef.current);
233
- }, [active, routesByUrl]);
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 logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} keepDocumentUrl={embedded} />
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
- {active === UPDATE_PAGE_ID ? (
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}>