@krapwoo/ds-viewer 0.14.2 → 0.16.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 +249 -3
- package/config/index.ts +3 -0
- package/dist/cli/config.js +9 -1
- package/dist/cli/doctor.js +452 -19
- package/dist/cli/explain.js +26 -0
- package/dist/cli/staticPage.js +99 -10
- package/dist/cli/tokenDefinition.js +125 -0
- package/dist/native/catalog/catalogNavigation.js +27 -11
- package/dist/native/catalog/pageApi.js +557 -26
- package/dist/native/catalog/tokenDefinition.js +205 -0
- package/native/catalog/CatalogSearchInput.tsx +18 -3
- package/native/catalog/CatalogShell.tsx +318 -46
- package/native/catalog/CatalogSidebar.tsx +37 -1
- package/native/catalog/FamilyPage.tsx +567 -137
- package/native/catalog/GroupPage.tsx +230 -0
- package/native/catalog/JumpRow.tsx +81 -0
- package/native/catalog/ReferenceDetails.tsx +144 -16
- package/native/catalog/SectionBlock.tsx +49 -5
- package/native/catalog/SourceReferencePicker.tsx +180 -0
- package/native/catalog/StructuredTokenPage.tsx +592 -0
- package/native/catalog/Swatch.tsx +29 -2
- package/native/catalog/TokenLayouts.tsx +30 -9
- package/native/catalog/catalogNavigation.ts +30 -10
- package/native/catalog/catalogReferences.ts +212 -0
- package/native/catalog/familyModel.ts +10 -4
- package/native/catalog/index.ts +22 -0
- package/native/catalog/pageApi.ts +598 -23
- package/native/catalog/tokenDefinition.ts +430 -0
- package/native/catalog/types.ts +77 -5
- package/package.json +1 -1
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -0
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -0
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +1 -0
- package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +1 -0
- package/starter-kit/components/Divider/Divider.catalog.tsx +1 -0
- package/starter-kit/components/Pill/Pill.catalog.tsx +1 -0
- package/starter-kit/components/TopNav/TopNav.catalog.tsx +1 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import React, { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import { AccessibilityInfo, Platform, Pressable, StyleSheet, Text, View, findNodeHandle, type LayoutChangeEvent } from 'react-native';
|
|
1
|
+
import React, { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { AccessibilityInfo, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, View, findNodeHandle, useWindowDimensions, type LayoutChangeEvent } from 'react-native';
|
|
3
|
+
import { CatalogButton } from './CatalogButton';
|
|
3
4
|
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
|
|
4
5
|
import { Blocks, SectionPagerRow, type SectionPager } from './SectionBlock';
|
|
5
6
|
import { PropsTable } from './PropsTable';
|
|
6
7
|
import { presentationBlocks } from './comparison';
|
|
7
8
|
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
8
9
|
import { hashForId } from './catalogNavigation';
|
|
10
|
+
import { referenceLinkProps, type ReferenceNavigation } from './catalogReferences';
|
|
9
11
|
// A plain extensionless import — the same one every other `.tsx` file here already uses for its
|
|
10
12
|
// own pure-module siblings (e.g. `CatalogShell.tsx` → `./catalogNavigation`): this file is a React
|
|
11
13
|
// component with no test of its own that imports it directly under Node, so it only ever goes
|
|
@@ -52,6 +54,93 @@ function memberAsSectionDef(member: CatalogMember): SectionDef {
|
|
|
52
54
|
|
|
53
55
|
const ARIA_HIDDEN = { 'aria-hidden': true } as Record<string, unknown>;
|
|
54
56
|
|
|
57
|
+
/** The one muted line under a family/group page's description: what kind of page this is and how
|
|
58
|
+
* it differs from a page of variants. The same wording on every page of that kind. */
|
|
59
|
+
const PAGE_KIND_EXPLAINER = {
|
|
60
|
+
group: {
|
|
61
|
+
lead: 'Group page.',
|
|
62
|
+
text: 'These are separate components shown together because they’re used in the same place. Each has its own API, unlike variants, which are one component changed by its props.',
|
|
63
|
+
},
|
|
64
|
+
family: {
|
|
65
|
+
lead: 'Family page.',
|
|
66
|
+
text: 'One component and the parts it’s built from. Each part is its own component, unlike variants, which are one component changed by its props.',
|
|
67
|
+
},
|
|
68
|
+
} as const;
|
|
69
|
+
|
|
70
|
+
/** #63 R2: the shared page head both `FamilyPage` and `GroupPage` render — breadcrumb, banner,
|
|
71
|
+
* title/description (plus an optional "Not shown in the browser" note) and the pager. Pulled out
|
|
72
|
+
* of `FamilyPage`'s own top-level render rather than duplicated, per the contract's "factor the
|
|
73
|
+
* head into a small shared piece rather than duplicating markup". */
|
|
74
|
+
export function FamilyPageHead<TId extends string>({
|
|
75
|
+
def, kind, groupLabel, breadcrumbRoot, banner, pager, headingRef, notShown,
|
|
76
|
+
}: {
|
|
77
|
+
/** Which page kind this head introduces — adds that kind's one-line explainer under the
|
|
78
|
+
* description (`PAGE_KIND_EXPLAINER`). */
|
|
79
|
+
kind: 'family' | 'group';
|
|
80
|
+
def: Pick<SectionDef<TId>, 'id' | 'title' | 'description'>;
|
|
81
|
+
groupLabel?: string;
|
|
82
|
+
breadcrumbRoot?: string;
|
|
83
|
+
banner?: React.ReactNode;
|
|
84
|
+
pager?: SectionPager<TId>;
|
|
85
|
+
headingRef?: React.Ref<View>;
|
|
86
|
+
/** See `FamilyDef.notShown`. */
|
|
87
|
+
notShown?: string;
|
|
88
|
+
}) {
|
|
89
|
+
return (
|
|
90
|
+
<>
|
|
91
|
+
{groupLabel && <Text style={styles.breadcrumb}>{breadcrumbRoot ? `${breadcrumbRoot} / ${groupLabel}` : groupLabel}</Text>}
|
|
92
|
+
{banner}
|
|
93
|
+
<View style={styles.pageHead}>
|
|
94
|
+
<View style={styles.titlebar}>
|
|
95
|
+
<View style={styles.titleText}>
|
|
96
|
+
<View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
|
|
97
|
+
<Text style={styles.title}>{def.title ?? def.id}</Text>
|
|
98
|
+
</View>
|
|
99
|
+
<Text style={styles.desc}>{def.description}</Text>
|
|
100
|
+
<View style={styles.kindNote}>
|
|
101
|
+
<Text style={styles.kindNoteText}>
|
|
102
|
+
<Text style={styles.kindNoteLead}>{PAGE_KIND_EXPLAINER[kind].lead} </Text>
|
|
103
|
+
{PAGE_KIND_EXPLAINER[kind].text}
|
|
104
|
+
</Text>
|
|
105
|
+
</View>
|
|
106
|
+
{notShown && (
|
|
107
|
+
<Text style={styles.notShown}>
|
|
108
|
+
<Text style={styles.notShownLead}>Not shown in the browser: </Text>
|
|
109
|
+
{notShown}
|
|
110
|
+
</Text>
|
|
111
|
+
)}
|
|
112
|
+
</View>
|
|
113
|
+
{pager && <SectionPagerRow pager={pager} />}
|
|
114
|
+
</View>
|
|
115
|
+
</View>
|
|
116
|
+
</>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Task 2B2: one path for every one of this file's own link producers (below-specimens/whole
|
|
121
|
+
* lines, Uses, related pages, anatomy legend) — `navigation` supplied: resolve+activate through
|
|
122
|
+
* the shell's own real `CatalogShell` journey (`referenceLinkProps`), the canonical address, even
|
|
123
|
+
* when it differs from `name` (an alias, or a component binding). `navigation` omitted (a
|
|
124
|
+
* standalone-mounted `FamilyPage`, this framework's own catalog demo included): keep the prior
|
|
125
|
+
* plain-`href`-only behavior, gated by the member's own `resolvesAddress` (sibling member ids,
|
|
126
|
+
* plus whatever `knownAddressIds` the caller passed) — never a dead link, same as before. */
|
|
127
|
+
function familyLink(
|
|
128
|
+
navigation: ReferenceNavigation | undefined,
|
|
129
|
+
resolvesAddress: (name: string) => boolean,
|
|
130
|
+
name: string,
|
|
131
|
+
/** Task 2B2 correction: the Part/whole that actually owns this call site (its Uses/related/
|
|
132
|
+
* "also used by" line) — see `ReferenceNavigation.open`'s own doc comment. Omitted for a
|
|
133
|
+
* page-level link producer (`relatedPages`, an anatomy "other page" entry, or the anatomy
|
|
134
|
+
* legend's own member row, which already names its destination). */
|
|
135
|
+
sourceMemberId?: string,
|
|
136
|
+
): { href: string; onPress?: (event?: { preventDefault?: () => void }) => void } | undefined {
|
|
137
|
+
if (navigation) {
|
|
138
|
+
const activation = referenceLinkProps(navigation, name, sourceMemberId);
|
|
139
|
+
return activation ? { href: hashForId(activation.memberId ?? activation.pageId), onPress: activation.onPress } : undefined;
|
|
140
|
+
}
|
|
141
|
+
return resolvesAddress(name) ? { href: hashForId(name) } : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
55
144
|
/** Fidelity badge (spec §5.2 "Badges"): no existing fidelity/OS badge to reuse, so this is a small
|
|
56
145
|
* pill on `microLabel`, reusing `SectionBlock`'s own OS-badge visual. `replicated` keeps that
|
|
57
146
|
* plain pill; `approximated` and `documentation-only` both get the gray/muted treatment the
|
|
@@ -140,6 +229,8 @@ function BelowSpecimensLine({
|
|
|
140
229
|
source,
|
|
141
230
|
alsoUsedBy,
|
|
142
231
|
resolvesAddress,
|
|
232
|
+
navigation,
|
|
233
|
+
sourceMemberId,
|
|
143
234
|
}: {
|
|
144
235
|
related?: RelatedSegment[];
|
|
145
236
|
showRelationship: boolean;
|
|
@@ -147,33 +238,39 @@ function BelowSpecimensLine({
|
|
|
147
238
|
source: string;
|
|
148
239
|
alsoUsedBy: AlsoUsedByEntry[];
|
|
149
240
|
resolvesAddress: (name: string) => boolean;
|
|
241
|
+
navigation?: ReferenceNavigation;
|
|
242
|
+
/** Task 2B2 correction: this line's own owning member — threaded into every link it renders, so
|
|
243
|
+
* a Back return lands on this member, not just the page (`familyLink`'s own doc comment). */
|
|
244
|
+
sourceMemberId?: string;
|
|
150
245
|
}) {
|
|
151
246
|
const usedBy = alsoUsedBySegments(alsoUsedBy);
|
|
152
247
|
return (
|
|
153
248
|
<Text style={styles.belowLine}>
|
|
154
249
|
{related &&
|
|
155
|
-
related.map((segment, i) =>
|
|
156
|
-
segment.memberId ? (
|
|
157
|
-
|
|
250
|
+
related.map((segment, i) => {
|
|
251
|
+
const link = segment.memberId ? familyLink(navigation, resolvesAddress, segment.memberId, sourceMemberId) : undefined;
|
|
252
|
+
return link ? (
|
|
253
|
+
<Text key={i} role="link" style={styles.relatedLink} {...link}>
|
|
158
254
|
{segment.text}
|
|
159
255
|
</Text>
|
|
160
256
|
) : (
|
|
161
257
|
<Text key={i}>{segment.text}</Text>
|
|
162
|
-
)
|
|
163
|
-
)}
|
|
258
|
+
);
|
|
259
|
+
})}
|
|
164
260
|
{related && ' · '}
|
|
165
261
|
{showRelationship && `${relationship} · `}
|
|
166
262
|
{source}
|
|
167
263
|
{usedBy.length > 0 && ' · '}
|
|
168
|
-
{usedBy.map((segment, i) =>
|
|
169
|
-
segment.component
|
|
170
|
-
|
|
264
|
+
{usedBy.map((segment, i) => {
|
|
265
|
+
const link = segment.component ? familyLink(navigation, resolvesAddress, segment.component, sourceMemberId) : undefined;
|
|
266
|
+
return link ? (
|
|
267
|
+
<Text key={i} role="link" style={styles.relatedLink} {...link}>
|
|
171
268
|
{segment.text}
|
|
172
269
|
</Text>
|
|
173
270
|
) : (
|
|
174
271
|
<Text key={i}>{segment.text}</Text>
|
|
175
|
-
)
|
|
176
|
-
)}
|
|
272
|
+
);
|
|
273
|
+
})}
|
|
177
274
|
</Text>
|
|
178
275
|
);
|
|
179
276
|
}
|
|
@@ -187,12 +284,14 @@ function WholeBelowLine({
|
|
|
187
284
|
source,
|
|
188
285
|
alsoUsedBy,
|
|
189
286
|
resolvesAddress,
|
|
287
|
+
navigation,
|
|
190
288
|
}: {
|
|
191
289
|
name: string;
|
|
192
290
|
fidelity: CatalogMember['fidelity'];
|
|
193
291
|
source: string;
|
|
194
292
|
alsoUsedBy: AlsoUsedByEntry[];
|
|
195
293
|
resolvesAddress: (name: string) => boolean;
|
|
294
|
+
navigation?: ReferenceNavigation;
|
|
196
295
|
}) {
|
|
197
296
|
const usedBy = alsoUsedBySegments(alsoUsedBy);
|
|
198
297
|
return (
|
|
@@ -203,17 +302,20 @@ function WholeBelowLine({
|
|
|
203
302
|
<Text style={styles.belowLine}> · </Text>
|
|
204
303
|
<Text style={styles.belowLine}>{source}</Text>
|
|
205
304
|
{usedBy.length > 0 && <Text style={styles.belowLine}> · </Text>}
|
|
206
|
-
{usedBy.map((segment, i) =>
|
|
207
|
-
|
|
208
|
-
|
|
305
|
+
{usedBy.map((segment, i) => {
|
|
306
|
+
// The whole's own inverse use returns to the whole wrapper, not just the page — `name` is
|
|
307
|
+
// this very member's own id (the whole is a member like any other).
|
|
308
|
+
const link = segment.component ? familyLink(navigation, resolvesAddress, segment.component, name) : undefined;
|
|
309
|
+
return link ? (
|
|
310
|
+
<Text key={i} role="link" style={styles.relatedLink} {...link}>
|
|
209
311
|
{segment.text}
|
|
210
312
|
</Text>
|
|
211
313
|
) : (
|
|
212
314
|
<Text key={i} style={styles.belowLine}>
|
|
213
315
|
{segment.text}
|
|
214
316
|
</Text>
|
|
215
|
-
)
|
|
216
|
-
)}
|
|
317
|
+
);
|
|
318
|
+
})}
|
|
217
319
|
</View>
|
|
218
320
|
);
|
|
219
321
|
}
|
|
@@ -222,23 +324,36 @@ function WholeBelowLine({
|
|
|
222
324
|
* the disclosure, after notes and before Guidance. A name links — the same accent treatment
|
|
223
325
|
* `BelowSpecimensLine`'s own `related` links use — when it resolves to a real catalog address (a
|
|
224
326
|
* page, member, or alias); otherwise it shows in `codeSmall`, since there's nothing to link to. */
|
|
225
|
-
function UsesLine({
|
|
327
|
+
function UsesLine({
|
|
328
|
+
composedOf,
|
|
329
|
+
resolvesAddress,
|
|
330
|
+
navigation,
|
|
331
|
+
sourceMemberId,
|
|
332
|
+
}: {
|
|
333
|
+
composedOf: ComposedOfEntry[];
|
|
334
|
+
resolvesAddress: (name: string) => boolean;
|
|
335
|
+
navigation?: ReferenceNavigation;
|
|
336
|
+
sourceMemberId?: string;
|
|
337
|
+
}) {
|
|
226
338
|
return (
|
|
227
339
|
<Text style={styles.usesLine}>
|
|
228
340
|
{'Uses '}
|
|
229
|
-
{composedOf.map((entry, i) =>
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
341
|
+
{composedOf.map((entry, i) => {
|
|
342
|
+
const link = familyLink(navigation, resolvesAddress, entry.component, sourceMemberId);
|
|
343
|
+
return (
|
|
344
|
+
<Text key={i}>
|
|
345
|
+
{link ? (
|
|
346
|
+
<Text role="link" style={styles.relatedLink} {...link}>
|
|
347
|
+
{entry.component}
|
|
348
|
+
</Text>
|
|
349
|
+
) : (
|
|
350
|
+
<Text style={styles.usesCode}>{entry.component}</Text>
|
|
351
|
+
)}
|
|
352
|
+
{` (${entry.role})`}
|
|
353
|
+
{i < composedOf.length - 1 ? ', ' : ''}
|
|
354
|
+
</Text>
|
|
355
|
+
);
|
|
356
|
+
})}
|
|
242
357
|
</Text>
|
|
243
358
|
);
|
|
244
359
|
}
|
|
@@ -253,11 +368,13 @@ function DisclosureBody({
|
|
|
253
368
|
parts,
|
|
254
369
|
resolvedProps,
|
|
255
370
|
resolvesAddress,
|
|
371
|
+
navigation,
|
|
256
372
|
}: {
|
|
257
373
|
member: CatalogMember;
|
|
258
374
|
parts: DisclosurePart[];
|
|
259
375
|
resolvedProps?: PropDef[];
|
|
260
376
|
resolvesAddress: (name: string) => boolean;
|
|
377
|
+
navigation?: ReferenceNavigation;
|
|
261
378
|
}) {
|
|
262
379
|
return (
|
|
263
380
|
<View style={styles.disclosureBody}>
|
|
@@ -268,7 +385,7 @@ function DisclosureBody({
|
|
|
268
385
|
{note}
|
|
269
386
|
</Text>
|
|
270
387
|
))}
|
|
271
|
-
{parts.includes('uses') && <UsesLine composedOf={member.composedOf!} resolvesAddress={resolvesAddress} />}
|
|
388
|
+
{parts.includes('uses') && <UsesLine composedOf={member.composedOf!} resolvesAddress={resolvesAddress} navigation={navigation} sourceMemberId={member.id} />}
|
|
272
389
|
{parts.includes('whenToUse') && (
|
|
273
390
|
<Text style={styles.disclosureText}>
|
|
274
391
|
<Text style={styles.disclosureLead}>Guidance </Text>
|
|
@@ -291,55 +408,186 @@ function DisclosureBody({
|
|
|
291
408
|
);
|
|
292
409
|
}
|
|
293
410
|
|
|
294
|
-
/**
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
|
|
298
|
-
function DisclosureChevron({ open }: { open: boolean }) {
|
|
411
|
+
/** The disclosure trigger's trailing chevron (`›`), on the `icon` role, hidden from screen readers
|
|
412
|
+
* (the trigger's own accessible name is its text label). Always `›`: the content opens in a side
|
|
413
|
+
* panel, never expands in place. */
|
|
414
|
+
function DisclosureChevron() {
|
|
299
415
|
return (
|
|
300
416
|
<Text {...ARIA_HIDDEN} style={styles.chevron}>
|
|
301
|
-
|
|
417
|
+
›
|
|
302
418
|
</Text>
|
|
303
419
|
);
|
|
304
420
|
}
|
|
305
421
|
|
|
306
|
-
/**
|
|
307
|
-
|
|
308
|
-
|
|
422
|
+
/** What the one shared side panel shows: the member's disclosure, headed by its trigger's label. */
|
|
423
|
+
interface MemberPanelRequest {
|
|
424
|
+
member: CatalogMember & { props?: PropDef[] };
|
|
425
|
+
parts: DisclosurePart[];
|
|
426
|
+
label: string;
|
|
427
|
+
resolvesAddress: (name: string) => boolean;
|
|
428
|
+
navigation?: ReferenceNavigation;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
interface MemberPanelControl {
|
|
432
|
+
/** The member whose panel is open, if any — a trigger's `aria-expanded`. */
|
|
433
|
+
activeId?: string;
|
|
434
|
+
show: (request: MemberPanelRequest) => void;
|
|
435
|
+
hide: () => void;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const MemberPanelContext = createContext<MemberPanelControl | null>(null);
|
|
439
|
+
|
|
440
|
+
const PANEL_WIDTH = 440;
|
|
441
|
+
/** At or below this window width the panel takes the full width, as the approved design shows at
|
|
442
|
+
* 768 (the viewer has no shared narrow breakpoint constant to reuse). */
|
|
443
|
+
const PANEL_FULL_WIDTH_AT_OR_BELOW = 768;
|
|
444
|
+
/** The light scrim behind the panel — no `CATALOG_COLOR` is a translucent overlay. */
|
|
445
|
+
const PANEL_SCRIM = 'rgba(20,20,20,.28)';
|
|
446
|
+
|
|
447
|
+
/** The member side panel: `role="dialog"`, `aria-modal`, named by the member, one at a time, no
|
|
448
|
+
* animation. On web, `Modal` (transparent) supplies the dialog role, Esc to close, Tab
|
|
449
|
+
* containment, and returning focus to the trigger on close. React Native's own `Modal` does none
|
|
450
|
+
* of those on native (only `onRequestClose` for the hardware back button), so on native the panel
|
|
451
|
+
* moves screen-reader focus to the × itself on open (`AccessibilityInfo.setAccessibilityFocus`);
|
|
452
|
+
* it does not return focus to the trigger on close — that is left to the platform. Following a
|
|
453
|
+
* link inside it closes it first (`navigation` is wrapped), then navigates. */
|
|
454
|
+
function MemberPanel({ request, onClose }: { request: MemberPanelRequest; onClose: () => void }) {
|
|
455
|
+
const { width } = useWindowDimensions();
|
|
456
|
+
const headingId = useId();
|
|
457
|
+
const closeRef = useRef<View>(null);
|
|
458
|
+
const focusClose = useCallback(() => {
|
|
459
|
+
const node = closeRef.current;
|
|
460
|
+
if (!node) return;
|
|
461
|
+
if (Platform.OS === 'web') {
|
|
462
|
+
node.focus?.();
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const handle = findNodeHandle(node);
|
|
466
|
+
if (handle != null) AccessibilityInfo.setAccessibilityFocus(handle);
|
|
467
|
+
}, []);
|
|
468
|
+
const { member, parts, label, resolvesAddress, navigation } = request;
|
|
469
|
+
const closingNavigation = useMemo<ReferenceNavigation | undefined>(
|
|
470
|
+
() =>
|
|
471
|
+
navigation && {
|
|
472
|
+
resolve: (name) => navigation.resolve(name),
|
|
473
|
+
open: (pageId, memberId, sourceMemberId) => {
|
|
474
|
+
onClose();
|
|
475
|
+
navigation.open(pageId, memberId, sourceMemberId);
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
[navigation, onClose],
|
|
479
|
+
);
|
|
480
|
+
// Locks the document's own scroll while the panel is open, for hosts whose body scrolls. The
|
|
481
|
+
// shell's own pane needs nothing: `Modal` portals outside it, so wheel chaining from the scrim
|
|
482
|
+
// never reaches it. Restores the previous overflow on close and on unmount. (iOS Safari ignores
|
|
483
|
+
// body overflow for touch scrolling.)
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
if (Platform.OS !== 'web' || typeof document === 'undefined') return;
|
|
486
|
+
const { body } = document;
|
|
487
|
+
const previous = body.style.overflow;
|
|
488
|
+
body.style.overflow = 'hidden';
|
|
489
|
+
return () => { body.style.overflow = previous; };
|
|
490
|
+
}, []);
|
|
491
|
+
// A plain-`href` link (no shell navigation) just changes the hash — close the panel on that too.
|
|
492
|
+
useEffect(() => {
|
|
493
|
+
if (Platform.OS !== 'web' || typeof window === 'undefined') return;
|
|
494
|
+
window.addEventListener('hashchange', onClose);
|
|
495
|
+
return () => window.removeEventListener('hashchange', onClose);
|
|
496
|
+
}, [onClose]);
|
|
497
|
+
return (
|
|
498
|
+
<Modal visible transparent animationType="none" onRequestClose={onClose} onShow={focusClose} {...({ 'aria-labelledby': headingId } as Record<string, unknown>)}>
|
|
499
|
+
<View style={styles.panelRoot}>
|
|
500
|
+
{/* On web, a plain View, not a Pressable: the Modal's focus trap `focus()`es descendants in turn, and
|
|
501
|
+
even a tabindex=-1 scrim would take focus — an unnamed, ringless stop. Press-to-close there is a
|
|
502
|
+
click handler only (the × and Esc are the keyboard paths). */}
|
|
503
|
+
{Platform.OS === 'web' ? (
|
|
504
|
+
<View testID="member-panel-scrim" style={styles.panelScrim} {...({ onClick: onClose } as Record<string, unknown>)} />
|
|
505
|
+
) : (
|
|
506
|
+
// Native has no DOM focus trap, so a Pressable is safe there and gives tap-to-close.
|
|
507
|
+
<Pressable testID="member-panel-scrim" style={styles.panelScrim} onPress={onClose} accessible={false} />
|
|
508
|
+
)}
|
|
509
|
+
<View style={[styles.panel, width <= PANEL_FULL_WIDTH_AT_OR_BELOW && styles.panelFull]}>
|
|
510
|
+
<View style={styles.panelHead}>
|
|
511
|
+
<View style={styles.panelTitle}>
|
|
512
|
+
<Text style={styles.panelKicker}>{label}</Text>
|
|
513
|
+
<Text nativeID={headingId} role="heading" {...({ 'aria-level': 2 } as Record<string, unknown>)} style={styles.panelName}>
|
|
514
|
+
{member.id}
|
|
515
|
+
</Text>
|
|
516
|
+
</View>
|
|
517
|
+
<CatalogButton ref={closeRef} label="×" accessibilityLabel="Close" variant="secondary" onPress={onClose} />
|
|
518
|
+
</View>
|
|
519
|
+
<ScrollView style={styles.panelBody} contentContainerStyle={styles.panelBodyContent}>
|
|
520
|
+
<DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} navigation={closingNavigation} />
|
|
521
|
+
</ScrollView>
|
|
522
|
+
</View>
|
|
523
|
+
</View>
|
|
524
|
+
</Modal>
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Owns the one shared side panel for a page: every member trigger below it opens it, a second
|
|
529
|
+
* trigger swaps its content, and only one is ever open. */
|
|
530
|
+
export function MemberPanelHost({ children }: { children: React.ReactNode }) {
|
|
531
|
+
const [request, setRequest] = useState<MemberPanelRequest | null>(null);
|
|
532
|
+
const hide = useCallback(() => setRequest(null), []);
|
|
533
|
+
const control = useMemo<MemberPanelControl>(() => ({ activeId: request?.member.id, show: setRequest, hide }), [request?.member.id, hide]);
|
|
534
|
+
return (
|
|
535
|
+
<MemberPanelContext.Provider value={control}>
|
|
536
|
+
{children}
|
|
537
|
+
{request && <MemberPanel request={request} onClose={hide} />}
|
|
538
|
+
</MemberPanelContext.Provider>
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Slice D1 item 9: "Notes and API" (using `apiLabel`) as a link-style trigger that opens the
|
|
543
|
+
* member's side panel — shared by the whole, every Part/Related member, and an open folded row.
|
|
544
|
+
* Renders nothing for a member with nothing to disclose. */
|
|
309
545
|
function MemberDisclosureToggle({
|
|
310
546
|
member,
|
|
311
547
|
apiLabel,
|
|
312
548
|
resolvesAddress,
|
|
549
|
+
navigation,
|
|
313
550
|
}: {
|
|
314
551
|
member: CatalogMember & { props?: PropDef[] };
|
|
315
552
|
apiLabel: string;
|
|
316
553
|
resolvesAddress: (name: string) => boolean;
|
|
554
|
+
navigation?: ReferenceNavigation;
|
|
317
555
|
}) {
|
|
318
|
-
const
|
|
556
|
+
const panel = useContext(MemberPanelContext);
|
|
319
557
|
const [focused, setFocused] = useState(false);
|
|
320
558
|
const parts = disclosureContent(member, member.props);
|
|
321
559
|
if (parts.length === 0) return null;
|
|
560
|
+
// A member rendered outside a page's own host still gets a working panel.
|
|
561
|
+
if (!panel) {
|
|
562
|
+
return (
|
|
563
|
+
<MemberPanelHost>
|
|
564
|
+
<MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} navigation={navigation} />
|
|
565
|
+
</MemberPanelHost>
|
|
566
|
+
);
|
|
567
|
+
}
|
|
322
568
|
const label = disclosureLabel(member, apiLabel, parts.includes('props'));
|
|
569
|
+
const open = panel.activeId === member.id;
|
|
323
570
|
return (
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
>
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
</Pressable>
|
|
337
|
-
{open && <DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} />}
|
|
338
|
-
</>
|
|
571
|
+
<Pressable
|
|
572
|
+
onPress={() => panel.show({ member, parts, label, resolvesAddress, navigation })}
|
|
573
|
+
onFocus={() => setFocused(true)}
|
|
574
|
+
onBlur={() => setFocused(false)}
|
|
575
|
+
accessibilityRole="button"
|
|
576
|
+
accessibilityState={{ expanded: open }}
|
|
577
|
+
{...({ 'aria-expanded': open, 'aria-haspopup': 'dialog' } as Record<string, unknown>)}
|
|
578
|
+
style={[styles.disclosureToggle, focused && styles.focusRing]}
|
|
579
|
+
>
|
|
580
|
+
<Text style={styles.disclosureToggleLabel}>{label}</Text>
|
|
581
|
+
<DisclosureChevron />
|
|
582
|
+
</Pressable>
|
|
339
583
|
);
|
|
340
584
|
}
|
|
341
585
|
|
|
342
|
-
|
|
586
|
+
/** #63 R2: exported so `GroupPage.tsx` can type its own `byId`/member records the same way
|
|
587
|
+
* `FamilyPage`'s internals already do — a group's own `FamilyDef.members` carries the same shape
|
|
588
|
+
* plus an optional `partOf` (`types.ts`), which this type simply doesn't need to name to stay
|
|
589
|
+
* assignable. */
|
|
590
|
+
export type FamilyMember = CatalogMember & { props?: PropDef[]; path: string; alsoUsedBy?: AlsoUsedByEntry[] };
|
|
343
591
|
|
|
344
592
|
interface MemberRenderProps {
|
|
345
593
|
member: FamilyMember;
|
|
@@ -354,6 +602,9 @@ interface MemberRenderProps {
|
|
|
354
602
|
/** V2d item 3: whether a name (a `composedOf` entry's `component`) resolves to a real catalog
|
|
355
603
|
* address — a page, member, or alias — so `UsesLine` knows which names to link. */
|
|
356
604
|
resolvesAddress: (name: string) => boolean;
|
|
605
|
+
/** Task 2B2: `CatalogShell`'s own shared reference navigation — see `familyLink`'s own doc
|
|
606
|
+
* comment above for how it and `resolvesAddress` combine. */
|
|
607
|
+
navigation?: ReferenceNavigation;
|
|
357
608
|
// Pressable forwards its ref to its underlying `View` (react-native's own `Pressable.d.ts`), so
|
|
358
609
|
// a registered node is always a `View` instance whether it came from a block member's `View`
|
|
359
610
|
// heading target or a folded row's `Pressable`.
|
|
@@ -368,7 +619,13 @@ interface MemberRenderProps {
|
|
|
368
619
|
* Related member (no pin, no gutter) — otherwise the same header and body: name, description,
|
|
369
620
|
* specimens (through `Blocks`, when the member has any), the below-specimens line, and a
|
|
370
621
|
* closed-by-default disclosure. */
|
|
371
|
-
|
|
622
|
+
/** #63 R2: exported so `GroupPage.tsx` can render a top-level group member through the exact same
|
|
623
|
+
* piece a family's own Part/Related member uses, with no second copy of its name row/specimens/
|
|
624
|
+
* below-line/disclosure rendering. `afterNameRow` is additive (`undefined` elsewhere, unchanged
|
|
625
|
+
* behavior for every existing family-page call site) — a group's own "Part N of <Family>" link
|
|
626
|
+
* (`PartOfLine`, below) renders there, right under the member's own name row and before its
|
|
627
|
+
* description, per the contract's own ordering. */
|
|
628
|
+
export function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths, apiLabel, resolvesAddress, navigation, registerNode, pinRef, afterNameRow }: MemberRenderProps & { level: number; afterNameRow?: React.ReactNode }) {
|
|
372
629
|
const surface = member.specimenSurface ?? DEFAULT_SPECIMEN_SURFACE;
|
|
373
630
|
const memberDef = useMemo(() => memberAsSectionDef(member), [member]);
|
|
374
631
|
const blocks = useMemo(
|
|
@@ -400,6 +657,7 @@ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths,
|
|
|
400
657
|
/>
|
|
401
658
|
</View>
|
|
402
659
|
</View>
|
|
660
|
+
{afterNameRow}
|
|
403
661
|
{/* D2 item A1: `partBody`'s own rhythm gap applies to every member, numbered or not — only
|
|
404
662
|
the gutter indent (`partBodyIndented`) is conditional — so a Related member (never
|
|
405
663
|
numbered) still gets the same spacing between its own description/specimens/below-line/
|
|
@@ -424,9 +682,11 @@ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths,
|
|
|
424
682
|
source={member.source}
|
|
425
683
|
alsoUsedBy={member.alsoUsedBy ?? []}
|
|
426
684
|
resolvesAddress={resolvesAddress}
|
|
685
|
+
navigation={navigation}
|
|
686
|
+
sourceMemberId={member.id}
|
|
427
687
|
/>
|
|
428
688
|
<View style={styles.disclosureSpacer}>
|
|
429
|
-
<MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
|
|
689
|
+
<MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} navigation={navigation} />
|
|
430
690
|
</View>
|
|
431
691
|
</View>
|
|
432
692
|
</View>
|
|
@@ -441,10 +701,9 @@ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths,
|
|
|
441
701
|
* show specimens (spec's v8 reference keeps every folded-row example documentation-only). Slice
|
|
442
702
|
* D1: no gutter either way — a folded row's own pin (when it has a number) sits inline with its
|
|
443
703
|
* name, never offsetting the row's body. */
|
|
444
|
-
function FoldedRow({ member, number, allMemberIds, resolvesAddress, open, onToggle, registerNode }: MemberRenderProps & { open: boolean; onToggle: () => void }) {
|
|
704
|
+
function FoldedRow({ member, number, allMemberIds, apiLabel, resolvesAddress, navigation, open, onToggle, registerNode }: MemberRenderProps & { open: boolean; onToggle: () => void }) {
|
|
445
705
|
const [focused, setFocused] = useState(false);
|
|
446
706
|
const related = member.related ? linkifyRelated(member.related, allMemberIds) : undefined;
|
|
447
|
-
const parts = disclosureContent(member, member.props);
|
|
448
707
|
return (
|
|
449
708
|
<View style={styles.foldedRow}>
|
|
450
709
|
<Pressable
|
|
@@ -475,10 +734,10 @@ function FoldedRow({ member, number, allMemberIds, resolvesAddress, open, onTogg
|
|
|
475
734
|
source={member.source}
|
|
476
735
|
alsoUsedBy={member.alsoUsedBy ?? []}
|
|
477
736
|
resolvesAddress={resolvesAddress}
|
|
737
|
+
navigation={navigation}
|
|
738
|
+
sourceMemberId={member.id}
|
|
478
739
|
/>
|
|
479
|
-
{
|
|
480
|
-
<DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} />
|
|
481
|
-
)}
|
|
740
|
+
<MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} navigation={navigation} />
|
|
482
741
|
</View>
|
|
483
742
|
)}
|
|
484
743
|
</View>
|
|
@@ -592,10 +851,55 @@ interface LegendFocus {
|
|
|
592
851
|
onBlur: (key: string) => void;
|
|
593
852
|
}
|
|
594
853
|
|
|
854
|
+
/** #63 R3: a family's own stub row for an *external part* — a member defined on a group page
|
|
855
|
+
* rather than this family's own `members` (`FamilyDef.externalParts`). Same pin/gutter as a real
|
|
856
|
+
* `FamilyPart`, but no specimens, description, or disclosure: only the name and a "Details on
|
|
857
|
+
* <group title>" link to that member's own address (`familyLink`, the same resolve-and-activate
|
|
858
|
+
* convention every other canonical link here uses — plain text, never a dead link, when it
|
|
859
|
+
* doesn't resolve). */
|
|
860
|
+
function ExternalPartRow({
|
|
861
|
+
memberId, number, groupTitle, resolvesAddress, navigation,
|
|
862
|
+
}: {
|
|
863
|
+
memberId: string;
|
|
864
|
+
number?: string;
|
|
865
|
+
groupTitle: string;
|
|
866
|
+
resolvesAddress: (name: string) => boolean;
|
|
867
|
+
navigation?: ReferenceNavigation;
|
|
868
|
+
}) {
|
|
869
|
+
const link = familyLink(navigation, resolvesAddress, memberId);
|
|
870
|
+
return (
|
|
871
|
+
<View>
|
|
872
|
+
<View style={styles.partHeadRow}>
|
|
873
|
+
{number !== undefined && (
|
|
874
|
+
<View style={styles.pinBox}>
|
|
875
|
+
<Pin number={number} />
|
|
876
|
+
</View>
|
|
877
|
+
)}
|
|
878
|
+
<View style={styles.partHeadMain}>
|
|
879
|
+
<Text style={[CATALOG_TEXT.heading2, styles.memberName]}>{memberId}</Text>
|
|
880
|
+
</View>
|
|
881
|
+
</View>
|
|
882
|
+
<View style={[styles.partBody, number !== undefined && styles.partBodyIndented]}>
|
|
883
|
+
<Text style={styles.belowLine}>
|
|
884
|
+
{link ? (
|
|
885
|
+
<Text role="link" style={styles.relatedLink} {...link}>
|
|
886
|
+
{`Details on ${groupTitle}`}
|
|
887
|
+
</Text>
|
|
888
|
+
) : (
|
|
889
|
+
<Text>{`Details on ${groupTitle}`}</Text>
|
|
890
|
+
)}
|
|
891
|
+
</Text>
|
|
892
|
+
</View>
|
|
893
|
+
</View>
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
595
897
|
/** One Part in its tree position: rendered through `FamilyPart` as always, wrapped in a `Branch`
|
|
596
898
|
* only when it has children (a leaf costs nothing extra), with its children nested inside its own
|
|
597
|
-
* block (spec: "so the lines don't cross other parts") at one more 52px gutter.
|
|
598
|
-
|
|
899
|
+
* block (spec: "so the lines don't cross other parts") at one more 52px gutter. #63 R2: exported
|
|
900
|
+
* so `GroupPage.tsx` can nest a top-level group member's own parts underneath it, the same tree
|
|
901
|
+
* (pins, tree lines, numbering) a family's own Parts section already renders. */
|
|
902
|
+
export function PartTreeBranch({
|
|
599
903
|
node,
|
|
600
904
|
byId,
|
|
601
905
|
outline,
|
|
@@ -603,8 +907,10 @@ function PartTreeBranch({
|
|
|
603
907
|
defaultPreviewWidths,
|
|
604
908
|
apiLabel,
|
|
605
909
|
resolvesAddress,
|
|
910
|
+
navigation,
|
|
606
911
|
registerNode,
|
|
607
912
|
pinNodesRef,
|
|
913
|
+
externalParts,
|
|
608
914
|
}: {
|
|
609
915
|
node: PartTreeEntry;
|
|
610
916
|
byId: ReadonlyMap<string, FamilyMember>;
|
|
@@ -613,12 +919,18 @@ function PartTreeBranch({
|
|
|
613
919
|
defaultPreviewWidths?: PreviewWidths;
|
|
614
920
|
apiLabel: string;
|
|
615
921
|
resolvesAddress: (name: string) => boolean;
|
|
922
|
+
navigation?: ReferenceNavigation;
|
|
616
923
|
registerNode: (id: string, node: View | null) => void;
|
|
617
924
|
pinNodesRef: React.RefObject<Map<string, View | null>>;
|
|
925
|
+
/** #63 R3: every id this family's own `inWhole` sections list that is actually a group-page
|
|
926
|
+
* member rather than one of `byId` above — rendered as `ExternalPartRow` instead of
|
|
927
|
+
* `FamilyPart`. Omitted (the default) outside a family page's own Parts tree — `GroupPage.tsx`'s
|
|
928
|
+
* own use of this same component never passes one, since a group never has external parts of
|
|
929
|
+
* its own. */
|
|
930
|
+
externalParts?: FamilyDef['externalParts'];
|
|
618
931
|
}) {
|
|
619
932
|
const member = byId.get(node.id);
|
|
620
|
-
|
|
621
|
-
const part = (
|
|
933
|
+
const part = member ? (
|
|
622
934
|
<FamilyPart
|
|
623
935
|
member={member}
|
|
624
936
|
number={outline.get(node.id)?.number}
|
|
@@ -627,13 +939,20 @@ function PartTreeBranch({
|
|
|
627
939
|
defaultPreviewWidths={defaultPreviewWidths}
|
|
628
940
|
apiLabel={apiLabel}
|
|
629
941
|
resolvesAddress={resolvesAddress}
|
|
942
|
+
navigation={navigation}
|
|
630
943
|
registerNode={registerNode}
|
|
631
944
|
pinRef={(pinNode) => {
|
|
632
945
|
if (pinNode) pinNodesRef.current.set(node.id, pinNode);
|
|
633
946
|
else pinNodesRef.current.delete(node.id);
|
|
634
947
|
}}
|
|
635
948
|
/>
|
|
636
|
-
)
|
|
949
|
+
) : (() => {
|
|
950
|
+
const external = externalParts?.get(node.id);
|
|
951
|
+
return external ? (
|
|
952
|
+
<ExternalPartRow memberId={node.id} number={outline.get(node.id)?.number} groupTitle={external.title} resolvesAddress={resolvesAddress} navigation={navigation} />
|
|
953
|
+
) : null;
|
|
954
|
+
})();
|
|
955
|
+
if (part === null) return null;
|
|
637
956
|
if (node.children.length === 0) return part;
|
|
638
957
|
return (
|
|
639
958
|
<Branch parentId={node.id} childIds={node.children.map((child) => child.id)} pinNodesRef={pinNodesRef}>
|
|
@@ -649,8 +968,10 @@ function PartTreeBranch({
|
|
|
649
968
|
defaultPreviewWidths={defaultPreviewWidths}
|
|
650
969
|
apiLabel={apiLabel}
|
|
651
970
|
resolvesAddress={resolvesAddress}
|
|
971
|
+
navigation={navigation}
|
|
652
972
|
registerNode={registerNode}
|
|
653
973
|
pinNodesRef={pinNodesRef}
|
|
974
|
+
externalParts={externalParts}
|
|
654
975
|
/>
|
|
655
976
|
))}
|
|
656
977
|
</View>
|
|
@@ -669,9 +990,11 @@ function FamilySectionGroup({
|
|
|
669
990
|
defaultPreviewWidths,
|
|
670
991
|
apiLabel,
|
|
671
992
|
resolvesAddress,
|
|
993
|
+
navigation,
|
|
672
994
|
openRows,
|
|
673
995
|
onToggleRow,
|
|
674
996
|
registerNode,
|
|
997
|
+
externalParts,
|
|
675
998
|
}: {
|
|
676
999
|
section: CatalogMemberSection;
|
|
677
1000
|
members: FamilyMember[];
|
|
@@ -680,14 +1003,22 @@ function FamilySectionGroup({
|
|
|
680
1003
|
defaultPreviewWidths?: PreviewWidths;
|
|
681
1004
|
apiLabel: string;
|
|
682
1005
|
resolvesAddress: (name: string) => boolean;
|
|
1006
|
+
navigation?: ReferenceNavigation;
|
|
683
1007
|
openRows: ReadonlySet<string>;
|
|
684
1008
|
onToggleRow: (id: string) => void;
|
|
685
1009
|
registerNode: (id: string, node: View | null) => void;
|
|
1010
|
+
/** #63 R3: see `PartTreeBranch`'s own doc comment. Omitted for the "Related" bucket — A5 only
|
|
1011
|
+
* ever applies to an `inWhole` section. */
|
|
1012
|
+
externalParts?: FamilyDef['externalParts'];
|
|
686
1013
|
}) {
|
|
687
1014
|
const byId = useMemo(() => new Map(members.map((member) => [member.id, member])), [members]);
|
|
688
1015
|
const pinNodesRef = useRef<Map<string, View | null>>(new Map());
|
|
1016
|
+
// #63 R3: `section.members` may list an external part — present in `externalParts` but never in
|
|
1017
|
+
// `byId` (it isn't one of this family's own `members`) — kept here (unlike a genuinely unknown
|
|
1018
|
+
// id, still dropped) so it gets its own `ExternalPartRow` stub below.
|
|
1019
|
+
const ids = section.members.filter((id) => byId.has(id) || externalParts?.has(id));
|
|
689
1020
|
if (section.kind === 'blocks') {
|
|
690
|
-
const tree = buildPartTree(
|
|
1021
|
+
const tree = buildPartTree(ids, outline);
|
|
691
1022
|
return (
|
|
692
1023
|
<View style={styles.blocksSection}>
|
|
693
1024
|
{tree.map((node) => (
|
|
@@ -700,8 +1031,10 @@ function FamilySectionGroup({
|
|
|
700
1031
|
defaultPreviewWidths={defaultPreviewWidths}
|
|
701
1032
|
apiLabel={apiLabel}
|
|
702
1033
|
resolvesAddress={resolvesAddress}
|
|
1034
|
+
navigation={navigation}
|
|
703
1035
|
registerNode={registerNode}
|
|
704
1036
|
pinNodesRef={pinNodesRef}
|
|
1037
|
+
externalParts={externalParts}
|
|
705
1038
|
/>
|
|
706
1039
|
))}
|
|
707
1040
|
</View>
|
|
@@ -709,21 +1042,30 @@ function FamilySectionGroup({
|
|
|
709
1042
|
}
|
|
710
1043
|
return (
|
|
711
1044
|
<View style={styles.foldedList}>
|
|
712
|
-
{
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
1045
|
+
{ids.map((id, i) => {
|
|
1046
|
+
const member = byId.get(id);
|
|
1047
|
+
const external = member ? undefined : externalParts?.get(id);
|
|
1048
|
+
return (
|
|
1049
|
+
<View key={id} style={[styles.foldedListRow, i < ids.length - 1 && styles.foldedListRowDivider]}>
|
|
1050
|
+
{member ? (
|
|
1051
|
+
<FoldedRow
|
|
1052
|
+
member={member}
|
|
1053
|
+
number={outline.get(id)?.number}
|
|
1054
|
+
allMemberIds={allMemberIds}
|
|
1055
|
+
defaultPreviewWidths={defaultPreviewWidths}
|
|
1056
|
+
apiLabel={apiLabel}
|
|
1057
|
+
resolvesAddress={resolvesAddress}
|
|
1058
|
+
navigation={navigation}
|
|
1059
|
+
open={openRows.has(id)}
|
|
1060
|
+
onToggle={() => onToggleRow(id)}
|
|
1061
|
+
registerNode={registerNode}
|
|
1062
|
+
/>
|
|
1063
|
+
) : external ? (
|
|
1064
|
+
<ExternalPartRow memberId={id} number={outline.get(id)?.number} groupTitle={external.title} resolvesAddress={resolvesAddress} navigation={navigation} />
|
|
1065
|
+
) : null}
|
|
1066
|
+
</View>
|
|
1067
|
+
);
|
|
1068
|
+
})}
|
|
727
1069
|
</View>
|
|
728
1070
|
);
|
|
729
1071
|
}
|
|
@@ -732,12 +1074,12 @@ function FamilySectionGroup({
|
|
|
732
1074
|
* page's own id as a link when it resolves to a real catalog address, plain text when not — never
|
|
733
1075
|
* a dead link — then its note, when authored. No specimens, no disclosure, no source line, no
|
|
734
1076
|
* number, no pin. */
|
|
735
|
-
function RelatedPageRow({ entry, resolvesAddress }: { entry: RelatedPage; resolvesAddress: (name: string) => boolean }) {
|
|
736
|
-
const
|
|
1077
|
+
function RelatedPageRow({ entry, resolvesAddress, navigation }: { entry: RelatedPage; resolvesAddress: (name: string) => boolean; navigation?: ReferenceNavigation }) {
|
|
1078
|
+
const link = familyLink(navigation, resolvesAddress, entry.page);
|
|
737
1079
|
return (
|
|
738
1080
|
<Text style={styles.belowLine}>
|
|
739
|
-
{
|
|
740
|
-
<Text role="link" style={styles.relatedPageName} {...
|
|
1081
|
+
{link ? (
|
|
1082
|
+
<Text role="link" style={styles.relatedPageName} {...link}>
|
|
741
1083
|
{entry.page}
|
|
742
1084
|
</Text>
|
|
743
1085
|
) : (
|
|
@@ -748,11 +1090,11 @@ function RelatedPageRow({ entry, resolvesAddress }: { entry: RelatedPage; resolv
|
|
|
748
1090
|
);
|
|
749
1091
|
}
|
|
750
1092
|
|
|
751
|
-
function RelatedPagesList({ items, resolvesAddress }: { items: readonly RelatedPage[]; resolvesAddress: (name: string) => boolean }) {
|
|
1093
|
+
function RelatedPagesList({ items, resolvesAddress, navigation }: { items: readonly RelatedPage[]; resolvesAddress: (name: string) => boolean; navigation?: ReferenceNavigation }) {
|
|
752
1094
|
return (
|
|
753
1095
|
<View style={styles.relatedPagesList}>
|
|
754
1096
|
{items.map((entry, i) => (
|
|
755
|
-
<RelatedPageRow key={i} entry={entry} resolvesAddress={resolvesAddress} />
|
|
1097
|
+
<RelatedPageRow key={i} entry={entry} resolvesAddress={resolvesAddress} navigation={navigation} />
|
|
756
1098
|
))}
|
|
757
1099
|
</View>
|
|
758
1100
|
);
|
|
@@ -774,9 +1116,11 @@ function FamilyLabeledGroup({
|
|
|
774
1116
|
defaultPreviewWidths,
|
|
775
1117
|
apiLabel,
|
|
776
1118
|
resolvesAddress,
|
|
1119
|
+
navigation,
|
|
777
1120
|
openRows,
|
|
778
1121
|
onToggleRow,
|
|
779
1122
|
registerNode,
|
|
1123
|
+
externalParts,
|
|
780
1124
|
}: {
|
|
781
1125
|
label: 'Parts' | 'Related';
|
|
782
1126
|
sections: CatalogMemberSection[];
|
|
@@ -788,9 +1132,13 @@ function FamilyLabeledGroup({
|
|
|
788
1132
|
defaultPreviewWidths?: PreviewWidths;
|
|
789
1133
|
apiLabel: string;
|
|
790
1134
|
resolvesAddress: (name: string) => boolean;
|
|
1135
|
+
navigation?: ReferenceNavigation;
|
|
791
1136
|
openRows: ReadonlySet<string>;
|
|
792
1137
|
onToggleRow: (id: string) => void;
|
|
793
1138
|
registerNode: (id: string, node: View | null) => void;
|
|
1139
|
+
/** #63 R3: see `PartTreeBranch`'s own doc comment — only ever passed for the "Parts" group
|
|
1140
|
+
* (A5 only applies to an `inWhole` section). */
|
|
1141
|
+
externalParts?: FamilyDef['externalParts'];
|
|
794
1142
|
}) {
|
|
795
1143
|
// Field spec item 3: "a page with no relatedPages and no Related sections renders exactly as it
|
|
796
1144
|
// does today — no heading, no divider, nothing."
|
|
@@ -818,21 +1166,37 @@ function FamilyLabeledGroup({
|
|
|
818
1166
|
defaultPreviewWidths={defaultPreviewWidths}
|
|
819
1167
|
apiLabel={apiLabel}
|
|
820
1168
|
resolvesAddress={resolvesAddress}
|
|
1169
|
+
navigation={navigation}
|
|
821
1170
|
openRows={openRows}
|
|
822
1171
|
onToggleRow={onToggleRow}
|
|
823
1172
|
registerNode={registerNode}
|
|
1173
|
+
externalParts={externalParts}
|
|
824
1174
|
/>
|
|
825
1175
|
))}
|
|
826
|
-
{relatedPages && relatedPages.length > 0 && <RelatedPagesList items={relatedPages} resolvesAddress={resolvesAddress} />}
|
|
1176
|
+
{relatedPages && relatedPages.length > 0 && <RelatedPagesList items={relatedPages} resolvesAddress={resolvesAddress} navigation={navigation} />}
|
|
827
1177
|
</View>
|
|
828
1178
|
</View>
|
|
829
1179
|
);
|
|
830
1180
|
}
|
|
831
1181
|
|
|
1182
|
+
/** What an anatomy pin looks up by member id: only the `fidelity` (dashed or solid). An own member
|
|
1183
|
+
* satisfies it as is; an external part is represented by a stub carrying its owner's fidelity
|
|
1184
|
+
* (`pinMembersWithExternal`). */
|
|
1185
|
+
type PinMembers = ReadonlyMap<string, Pick<FamilyMember, 'fidelity'>>;
|
|
1186
|
+
|
|
1187
|
+
/** `byId` plus one fidelity-only entry per external part (a member documented on a group page), so
|
|
1188
|
+
* its anatomy pin is dashed or solid by its owner's fidelity, exactly like an own part's. */
|
|
1189
|
+
function pinMembersWithExternal(byId: ReadonlyMap<string, FamilyMember>, externalParts: FamilyDef['externalParts']): PinMembers {
|
|
1190
|
+
if (!externalParts || externalParts.size === 0) return byId;
|
|
1191
|
+
const merged = new Map<string, Pick<FamilyMember, 'fidelity'>>(byId);
|
|
1192
|
+
for (const [id, external] of externalParts) if (!merged.has(id) && external.fidelity) merged.set(id, { fidelity: external.fidelity });
|
|
1193
|
+
return merged;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
832
1196
|
/** Slice D1 item 5: the family page's own member records and computed outline, read by `AnatomyPin`
|
|
833
1197
|
* so a page's `anatomy.picture` (author-supplied markup, possibly arbitrarily positioned) never
|
|
834
1198
|
* hardcodes a number. `undefined` outside a family page's own anatomy box. */
|
|
835
|
-
const FamilyAnatomyContext = createContext<{ byId:
|
|
1199
|
+
const FamilyAnatomyContext = createContext<{ byId: PinMembers; outline: FamilyDef['outline'] } | undefined>(undefined);
|
|
836
1200
|
|
|
837
1201
|
/** Slice D1 item 5: a pin for use inside `anatomy.picture`. `member` names one of this page's
|
|
838
1202
|
* members and shows its number (dashed when it has no area of its own), looked up through
|
|
@@ -862,8 +1226,12 @@ export function AnatomyPin({ member, other }: { member?: string; other?: string
|
|
|
862
1226
|
* when there's a pin to show — a member with no number (outside every `inWhole` section) gets no
|
|
863
1227
|
* gutter either, so its name starts flush at the row's own left edge instead of under blank
|
|
864
1228
|
* space. */
|
|
865
|
-
function AnatomyMemberRow({ memberId, description, byId, outline, legendFocus }: { memberId: string; description: string; byId:
|
|
1229
|
+
function AnatomyMemberRow({ memberId, description, byId, outline, legendFocus, resolvesAddress, navigation }: { memberId: string; description: string; byId: PinMembers; outline: FamilyDef['outline']; legendFocus: LegendFocus; resolvesAddress: (name: string) => boolean; navigation?: ReferenceNavigation }) {
|
|
866
1230
|
const pin = anatomyPinFor(memberId, byId, outline);
|
|
1231
|
+
// `familyLink` already resolves this row's own member (`resolvesAddress`'s sibling ids, at
|
|
1232
|
+
// minimum, cover it) — an explicitly supplied `navigation` that still can't resolve it must stay
|
|
1233
|
+
// honest plain text, never a raw `hashForId(memberId)` escape hatch around that refusal.
|
|
1234
|
+
const link = familyLink(navigation, resolvesAddress, memberId);
|
|
867
1235
|
return (
|
|
868
1236
|
<View style={styles.legendRow}>
|
|
869
1237
|
{pin && (
|
|
@@ -875,14 +1243,18 @@ function AnatomyMemberRow({ memberId, description, byId, outline, legendFocus }:
|
|
|
875
1243
|
</View>
|
|
876
1244
|
)}
|
|
877
1245
|
<View style={styles.legendBody}>
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1246
|
+
{link ? (
|
|
1247
|
+
<Text
|
|
1248
|
+
role="link"
|
|
1249
|
+
style={styles.legendName}
|
|
1250
|
+
ref={(node) => legendFocus.register(memberId, node as unknown as FocusableNode)}
|
|
1251
|
+
{...({ ...link, onFocus: () => legendFocus.onFocus(memberId), onBlur: () => legendFocus.onBlur(memberId) } as Record<string, unknown>)}
|
|
1252
|
+
>
|
|
1253
|
+
{memberId}
|
|
1254
|
+
</Text>
|
|
1255
|
+
) : (
|
|
1256
|
+
<Text style={styles.legendName}>{memberId}</Text>
|
|
1257
|
+
)}
|
|
886
1258
|
<Text style={styles.legendDesc}>{description}</Text>
|
|
887
1259
|
</View>
|
|
888
1260
|
</View>
|
|
@@ -895,8 +1267,8 @@ function AnatomyMemberRow({ memberId, description, byId, outline, legendFocus }:
|
|
|
895
1267
|
* "other page" row always shows its ↗, so the slot is never blank. Keeping it also keeps
|
|
896
1268
|
* APPROVED.md's "the description ... starts at the name's left edge" — pulling the marker inline
|
|
897
1269
|
* instead left each description hanging 26px to the left of its own name. */
|
|
898
|
-
function AnatomyOtherRow({ name, description, resolvesAddress, legendFocus }: { name: string; description: string; resolvesAddress: (name: string) => boolean; legendFocus: LegendFocus }) {
|
|
899
|
-
const
|
|
1270
|
+
function AnatomyOtherRow({ name, description, resolvesAddress, navigation, legendFocus }: { name: string; description: string; resolvesAddress: (name: string) => boolean; navigation?: ReferenceNavigation; legendFocus: LegendFocus }) {
|
|
1271
|
+
const link = familyLink(navigation, resolvesAddress, name);
|
|
900
1272
|
// No member id of its own (it names a component documented elsewhere), so it needs its own
|
|
901
1273
|
// namespace to never collide with a real member id sharing the same text.
|
|
902
1274
|
const focusKey = `other:${name}`;
|
|
@@ -909,12 +1281,12 @@ function AnatomyOtherRow({ name, description, resolvesAddress, legendFocus }: {
|
|
|
909
1281
|
</View>
|
|
910
1282
|
<View style={styles.legendBody}>
|
|
911
1283
|
<View style={styles.legendNameRow}>
|
|
912
|
-
{
|
|
1284
|
+
{link ? (
|
|
913
1285
|
<Text
|
|
914
1286
|
role="link"
|
|
915
1287
|
style={styles.legendName}
|
|
916
1288
|
ref={(node) => legendFocus.register(focusKey, node as unknown as FocusableNode)}
|
|
917
|
-
{...({
|
|
1289
|
+
{...({ ...link, onFocus: () => legendFocus.onFocus(focusKey), onBlur: () => legendFocus.onBlur(focusKey) } as Record<string, unknown>)}
|
|
918
1290
|
>
|
|
919
1291
|
{name}
|
|
920
1292
|
</Text>
|
|
@@ -929,15 +1301,15 @@ function AnatomyOtherRow({ name, description, resolvesAddress, legendFocus }: {
|
|
|
929
1301
|
);
|
|
930
1302
|
}
|
|
931
1303
|
|
|
932
|
-
function AnatomyGroup({ group, byId, outline, resolvesAddress, legendFocus }: { group: CatalogAnatomyGroup; byId:
|
|
1304
|
+
function AnatomyGroup({ group, byId, outline, resolvesAddress, navigation, legendFocus }: { group: CatalogAnatomyGroup; byId: PinMembers; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean; navigation?: ReferenceNavigation; legendFocus: LegendFocus }) {
|
|
933
1305
|
return (
|
|
934
1306
|
<View>
|
|
935
1307
|
<Text style={styles.legendGroupLabel}>{group.label}</Text>
|
|
936
1308
|
{group.items.map((item, i) =>
|
|
937
1309
|
'member' in item ? (
|
|
938
|
-
<AnatomyMemberRow key={i} memberId={item.member} description={item.description} byId={byId} outline={outline} legendFocus={legendFocus} />
|
|
1310
|
+
<AnatomyMemberRow key={i} memberId={item.member} description={item.description} byId={byId} outline={outline} legendFocus={legendFocus} resolvesAddress={resolvesAddress} navigation={navigation} />
|
|
939
1311
|
) : (
|
|
940
|
-
<AnatomyOtherRow key={i} name={item.other} description={item.description} resolvesAddress={resolvesAddress} legendFocus={legendFocus} />
|
|
1312
|
+
<AnatomyOtherRow key={i} name={item.other} description={item.description} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
|
|
941
1313
|
),
|
|
942
1314
|
)}
|
|
943
1315
|
</View>
|
|
@@ -953,8 +1325,13 @@ function AnatomyGroup({ group, byId, outline, resolvesAddress, legendFocus }: {
|
|
|
953
1325
|
* The stage (and the picture inside it) renders at the same position in this tree whether the
|
|
954
1326
|
* legend sits beside it or below it — only the wrapper's own `flexDirection` and the legend's own
|
|
955
1327
|
* shape change around it — so toggling between the two never remounts the picture and discards
|
|
956
|
-
* its own authored state or focus.
|
|
957
|
-
|
|
1328
|
+
* its own authored state or focus.
|
|
1329
|
+
*
|
|
1330
|
+
* #63 R2: exported so `GroupPage.tsx` can render an embedded family's own anatomy box — several
|
|
1331
|
+
* may exist on one group page (one per top-level member that has one), each its own independent
|
|
1332
|
+
* instance of this component (its own `FamilyAnatomyContext`/measurement state), not a
|
|
1333
|
+
* page-singleton. */
|
|
1334
|
+
export function AnatomyBox({ anatomy, byId, outline, resolvesAddress, navigation }: { anatomy: CatalogAnatomy; byId: PinMembers; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean; navigation?: ReferenceNavigation }) {
|
|
958
1335
|
const totalItems = anatomy.groups.reduce((sum, group) => sum + group.items.length, 0);
|
|
959
1336
|
const [cardWidth, setCardWidth] = useState(0);
|
|
960
1337
|
const [pictureWidth, setPictureWidth] = useState(0);
|
|
@@ -1042,6 +1419,15 @@ function AnatomyBox({ anatomy, byId, outline, resolvesAddress }: { anatomy: Cata
|
|
|
1042
1419
|
<View style={styles.anatomy}>
|
|
1043
1420
|
<Text style={styles.anatomyHeadText}>Anatomy</Text>
|
|
1044
1421
|
<View style={styles.anatomyCard}>
|
|
1422
|
+
{anatomy.picture == null ? (
|
|
1423
|
+
// No picture (e.g. mutually exclusive parts that never share one frame): the legend
|
|
1424
|
+
// takes the card's full width instead of sitting beside an empty picture column.
|
|
1425
|
+
<View style={styles.anatomyLegendAlone} testID="anatomy-legend">
|
|
1426
|
+
{anatomy.groups.map((group, i) => (
|
|
1427
|
+
<AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
|
|
1428
|
+
))}
|
|
1429
|
+
</View>
|
|
1430
|
+
) : (
|
|
1045
1431
|
<View style={stacked ? undefined : styles.anatomyBody} onLayout={onCardLayout} testID="anatomy-body">
|
|
1046
1432
|
{stage}
|
|
1047
1433
|
{stacked ? (
|
|
@@ -1052,7 +1438,7 @@ function AnatomyBox({ anatomy, byId, outline, resolvesAddress }: { anatomy: Cata
|
|
|
1052
1438
|
{anatomyColumns(anatomy.groups, legendColumns).map((column, i) => (
|
|
1053
1439
|
<View key={i} style={styles.anatomyColumn}>
|
|
1054
1440
|
{column.map((group, j) => (
|
|
1055
|
-
<AnatomyGroup key={j} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} legendFocus={legendFocus} />
|
|
1441
|
+
<AnatomyGroup key={j} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
|
|
1056
1442
|
))}
|
|
1057
1443
|
</View>
|
|
1058
1444
|
))}
|
|
@@ -1060,23 +1446,55 @@ function AnatomyBox({ anatomy, byId, outline, resolvesAddress }: { anatomy: Cata
|
|
|
1060
1446
|
) : (
|
|
1061
1447
|
<View style={styles.anatomyLegend} testID="anatomy-legend">
|
|
1062
1448
|
{anatomy.groups.map((group, i) => (
|
|
1063
|
-
<AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} legendFocus={legendFocus} />
|
|
1449
|
+
<AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
|
|
1064
1450
|
))}
|
|
1065
1451
|
</View>
|
|
1066
1452
|
)}
|
|
1067
1453
|
</View>
|
|
1454
|
+
)}
|
|
1068
1455
|
</View>
|
|
1069
1456
|
</View>
|
|
1070
1457
|
</FamilyAnatomyContext.Provider>
|
|
1071
1458
|
);
|
|
1072
1459
|
}
|
|
1073
1460
|
|
|
1461
|
+
/** #63 R2: a group member that is also an external part of some family's own outline — a plain
|
|
1462
|
+
* link line, "Part N of <Family>", to the family page (never to a specific member address — the
|
|
1463
|
+
* family page has no separate address for one of its own parts). Reuses this file's own
|
|
1464
|
+
* `belowLine`/`relatedLink` styles rather than a new visual; renders through `familyLink` so it
|
|
1465
|
+
* resolves and activates exactly like every other canonical link `FamilyPage`'s own pieces
|
|
1466
|
+
* produce (plain text, never a dead link, when it doesn't resolve). */
|
|
1467
|
+
export function PartOfLine({
|
|
1468
|
+
partOf, resolvesAddress, navigation,
|
|
1469
|
+
}: {
|
|
1470
|
+
partOf: { pageId: string; title: string; number: string };
|
|
1471
|
+
resolvesAddress: (name: string) => boolean;
|
|
1472
|
+
navigation?: ReferenceNavigation;
|
|
1473
|
+
}) {
|
|
1474
|
+
const link = familyLink(navigation, resolvesAddress, partOf.pageId);
|
|
1475
|
+
return (
|
|
1476
|
+
<Text style={styles.belowLine}>
|
|
1477
|
+
{`Part ${partOf.number} of `}
|
|
1478
|
+
{link ? (
|
|
1479
|
+
<Text role="link" style={styles.relatedLink} {...link}>
|
|
1480
|
+
{partOf.title}
|
|
1481
|
+
</Text>
|
|
1482
|
+
) : (
|
|
1483
|
+
<Text>{partOf.title}</Text>
|
|
1484
|
+
)}
|
|
1485
|
+
</Text>
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1074
1489
|
/** Scrolls a member's node into view and moves focus to it (spec §5.3 "Member address"). The jump
|
|
1075
1490
|
* is always instant, like a normal anchor link: a smooth `scrollIntoView` does not reliably move
|
|
1076
1491
|
* the shell's nested scroll container on web (the same limitation `CatalogShell`'s
|
|
1077
1492
|
* `resetMainScroll` works around), and an instant jump also satisfies Reduce Motion. Native has
|
|
1078
|
-
* no scroll-into-view of its own; `AccessibilityInfo`'s focus call is enough there.
|
|
1079
|
-
|
|
1493
|
+
* no scroll-into-view of its own; `AccessibilityInfo`'s focus call is enough there.
|
|
1494
|
+
*
|
|
1495
|
+
* #63 R2: exported so `GroupPage.tsx` reuses the exact same jump mechanics for its own member
|
|
1496
|
+
* addresses, rather than a second implementation. */
|
|
1497
|
+
export function scrollAndFocusMember(node: View): void {
|
|
1080
1498
|
if (Platform.OS === 'web') {
|
|
1081
1499
|
const dom = node as unknown as { scrollIntoView?: (options: { behavior: 'auto'; block: 'start' }) => void; focus?: (options?: { preventScroll?: boolean }) => void };
|
|
1082
1500
|
dom.scrollIntoView?.({ behavior: 'auto', block: 'start' });
|
|
@@ -1105,6 +1523,7 @@ export function FamilyPage<TId extends string>({
|
|
|
1105
1523
|
focusMemberId,
|
|
1106
1524
|
focusNonce,
|
|
1107
1525
|
knownAddressIds,
|
|
1526
|
+
navigation,
|
|
1108
1527
|
}: {
|
|
1109
1528
|
def: SectionDef<TId>;
|
|
1110
1529
|
groupLabel?: string;
|
|
@@ -1130,10 +1549,19 @@ export function FamilyPage<TId extends string>({
|
|
|
1130
1549
|
* omitted when `FamilyPage` is used standalone (e.g. this very page's own live demo), where
|
|
1131
1550
|
* only this family's own members can resolve. */
|
|
1132
1551
|
knownAddressIds?: ReadonlySet<string>;
|
|
1552
|
+
/** Task 2B2: `CatalogShell`'s own shared reference navigation (see `catalogReferences.ts`'s
|
|
1553
|
+
* `ReferenceNavigation`/`referenceLinkProps`) — every one of this page's own link producers
|
|
1554
|
+
* (below-specimens/whole lines, Uses, related pages, anatomy legend) resolves and activates
|
|
1555
|
+
* through it when supplied, retaining this family's own sibling-member addresses exactly as
|
|
1556
|
+
* `resolvesAddress` below already does. Omitted (a standalone-mounted `FamilyPage`, this
|
|
1557
|
+
* framework's own catalog demo included) keeps every one of them at its prior plain-`href`-only
|
|
1558
|
+
* behavior, unchanged. */
|
|
1559
|
+
navigation?: ReferenceNavigation<TId>;
|
|
1133
1560
|
}) {
|
|
1134
1561
|
const family = def.family!;
|
|
1135
1562
|
const apiLabel = family.apiLabel ?? 'API';
|
|
1136
1563
|
const byId = useMemo(() => new Map(family.members.map((member) => [member.id, member])), [family.members]);
|
|
1564
|
+
const anatomyById = useMemo(() => pinMembersWithExternal(byId, family.externalParts), [byId, family.externalParts]);
|
|
1137
1565
|
// N7: a `whole` that isn't one of its own members (bad data, already reported by
|
|
1138
1566
|
// `validateFamily`) must degrade to a missing block, never a crash reading an absent member.
|
|
1139
1567
|
const wholeMember = resolveWhole(byId, family.whole);
|
|
@@ -1190,28 +1618,11 @@ export function FamilyPage<TId extends string>({
|
|
|
1190
1618
|
}, [focusMemberId, openRows, focusNonce]);
|
|
1191
1619
|
|
|
1192
1620
|
return (
|
|
1621
|
+
<MemberPanelHost>
|
|
1193
1622
|
<View>
|
|
1194
|
-
|
|
1195
|
-
{banner}
|
|
1196
|
-
<View style={styles.pageHead}>
|
|
1197
|
-
<View style={styles.titlebar}>
|
|
1198
|
-
<View style={styles.titleText}>
|
|
1199
|
-
<View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
|
|
1200
|
-
<Text style={styles.title}>{def.title ?? def.id}</Text>
|
|
1201
|
-
</View>
|
|
1202
|
-
<Text style={styles.desc}>{def.description}</Text>
|
|
1203
|
-
{family.notShown && (
|
|
1204
|
-
<Text style={styles.notShown}>
|
|
1205
|
-
<Text style={styles.notShownLead}>Not shown in the browser: </Text>
|
|
1206
|
-
{family.notShown}
|
|
1207
|
-
</Text>
|
|
1208
|
-
)}
|
|
1209
|
-
</View>
|
|
1210
|
-
{pager && <SectionPagerRow pager={pager} />}
|
|
1211
|
-
</View>
|
|
1212
|
-
</View>
|
|
1623
|
+
<FamilyPageHead kind="family" def={def} groupLabel={groupLabel} breadcrumbRoot={breadcrumbRoot} banner={banner} pager={pager} headingRef={headingRef} notShown={family.notShown} />
|
|
1213
1624
|
|
|
1214
|
-
{family.anatomy && <AnatomyBox anatomy={family.anatomy} byId={
|
|
1625
|
+
{family.anatomy && <AnatomyBox anatomy={family.anatomy} byId={anatomyById} outline={family.outline} resolvesAddress={resolvesAddress} navigation={navigation} />}
|
|
1215
1626
|
|
|
1216
1627
|
{wholeMember && (
|
|
1217
1628
|
<View style={styles.whole} ref={(node) => registerNode(wholeMember.id, node)} tabIndex={-1}>
|
|
@@ -1233,9 +1644,10 @@ export function FamilyPage<TId extends string>({
|
|
|
1233
1644
|
source={wholeMember.source}
|
|
1234
1645
|
alsoUsedBy={wholeMember.alsoUsedBy ?? []}
|
|
1235
1646
|
resolvesAddress={resolvesAddress}
|
|
1647
|
+
navigation={navigation}
|
|
1236
1648
|
/>
|
|
1237
1649
|
<View style={styles.disclosureSpacer}>
|
|
1238
|
-
<MemberDisclosureToggle member={wholeMember} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
|
|
1650
|
+
<MemberDisclosureToggle member={wholeMember} apiLabel={apiLabel} resolvesAddress={resolvesAddress} navigation={navigation} />
|
|
1239
1651
|
</View>
|
|
1240
1652
|
</>
|
|
1241
1653
|
);
|
|
@@ -1252,9 +1664,11 @@ export function FamilyPage<TId extends string>({
|
|
|
1252
1664
|
defaultPreviewWidths={defaultPreviewWidths}
|
|
1253
1665
|
apiLabel={apiLabel}
|
|
1254
1666
|
resolvesAddress={resolvesAddress}
|
|
1667
|
+
navigation={navigation}
|
|
1255
1668
|
openRows={openRows}
|
|
1256
1669
|
onToggleRow={toggleRow}
|
|
1257
1670
|
registerNode={registerNode}
|
|
1671
|
+
externalParts={family.externalParts}
|
|
1258
1672
|
/>
|
|
1259
1673
|
<FamilyLabeledGroup
|
|
1260
1674
|
label="Related"
|
|
@@ -1266,11 +1680,13 @@ export function FamilyPage<TId extends string>({
|
|
|
1266
1680
|
defaultPreviewWidths={defaultPreviewWidths}
|
|
1267
1681
|
apiLabel={apiLabel}
|
|
1268
1682
|
resolvesAddress={resolvesAddress}
|
|
1683
|
+
navigation={navigation}
|
|
1269
1684
|
openRows={openRows}
|
|
1270
1685
|
onToggleRow={toggleRow}
|
|
1271
1686
|
registerNode={registerNode}
|
|
1272
1687
|
/>
|
|
1273
1688
|
</View>
|
|
1689
|
+
</MemberPanelHost>
|
|
1274
1690
|
);
|
|
1275
1691
|
}
|
|
1276
1692
|
|
|
@@ -1287,6 +1703,9 @@ const styles = StyleSheet.create({
|
|
|
1287
1703
|
title: { ...CATALOG_TEXT.pageTitle, color: CATALOG_COLOR.text, marginBottom: 5 },
|
|
1288
1704
|
desc: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
|
|
1289
1705
|
notShown: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, maxWidth: 700, marginTop: CATALOG_SPACE.sm },
|
|
1706
|
+
kindNote: { maxWidth: 700, marginTop: CATALOG_SPACE.sm, borderLeftWidth: 2, borderLeftColor: CATALOG_COLOR.borderStrong, paddingLeft: CATALOG_SPACE.md, paddingVertical: 2 },
|
|
1707
|
+
kindNoteText: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted },
|
|
1708
|
+
kindNoteLead: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
|
|
1290
1709
|
notShownLead: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
|
|
1291
1710
|
|
|
1292
1711
|
// Slice D1 item 5: the anatomy box's own "Anatomy" label sits outside, above a plain card —
|
|
@@ -1298,6 +1717,7 @@ const styles = StyleSheet.create({
|
|
|
1298
1717
|
anatomyBody: { flexDirection: 'row', alignItems: 'flex-start' },
|
|
1299
1718
|
anatomyStage: { padding: CATALOG_SPACE.xl, alignItems: 'center', justifyContent: 'center' },
|
|
1300
1719
|
anatomyLegend: { flex: 1, borderLeftWidth: 1, borderLeftColor: CATALOG_COLOR.border, paddingVertical: CATALOG_SPACE.sm },
|
|
1720
|
+
anatomyLegendAlone: { paddingVertical: CATALOG_SPACE.sm },
|
|
1301
1721
|
anatomyColumns: { flexDirection: 'row', borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
|
|
1302
1722
|
anatomyColumn: { flex: 1, borderLeftWidth: 1, borderLeftColor: CATALOG_COLOR.border, paddingVertical: CATALOG_SPACE.sm },
|
|
1303
1723
|
// Host-authored text (`anatomy.groups[].label`) — never a transforming role like `microLabel`,
|
|
@@ -1402,6 +1822,16 @@ const styles = StyleSheet.create({
|
|
|
1402
1822
|
justifyContent: 'center',
|
|
1403
1823
|
},
|
|
1404
1824
|
chevron: { ...CATALOG_TEXT.icon, color: CATALOG_COLOR.accent },
|
|
1825
|
+
panelRoot: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end' },
|
|
1826
|
+
panelScrim: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: PANEL_SCRIM },
|
|
1827
|
+
panel: { width: PANEL_WIDTH, maxWidth: '100%', height: '100%', backgroundColor: CATALOG_COLOR.surface, borderLeftWidth: 1, borderLeftColor: CATALOG_COLOR.borderStrong },
|
|
1828
|
+
panelFull: { width: '100%' },
|
|
1829
|
+
panelHead: { flexDirection: 'row', alignItems: 'flex-start', gap: CATALOG_SPACE.md, padding: CATALOG_SPACE.lg, borderBottomWidth: 1, borderBottomColor: CATALOG_COLOR.border },
|
|
1830
|
+
panelTitle: { flex: 1, gap: 2 },
|
|
1831
|
+
panelKicker: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.textMuted },
|
|
1832
|
+
panelName: { ...CATALOG_TEXT.heading3, color: CATALOG_COLOR.text },
|
|
1833
|
+
panelBody: { flex: 1 },
|
|
1834
|
+
panelBodyContent: { padding: CATALOG_SPACE.lg },
|
|
1405
1835
|
disclosureToggleLabel: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.accent },
|
|
1406
1836
|
disclosureBody: { gap: CATALOG_SPACE.sm, paddingTop: CATALOG_SPACE.xs },
|
|
1407
1837
|
note: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.text },
|