@krapwoo/ds-viewer 0.14.1 → 0.15.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 +224 -2
- package/dist/cli/doctor.js +306 -17
- package/dist/cli/explain.js +26 -0
- package/dist/cli/staticPage.js +90 -6
- 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 +702 -153
- 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 +50 -4
- package/native/catalog/index.ts +22 -0
- package/native/catalog/pageApi.ts +595 -23
- package/native/catalog/tokenDefinition.ts +430 -0
- package/native/catalog/types.ts +77 -5
- package/package.json +1 -1
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import { AccessibilityInfo, Platform, Pressable, StyleSheet, Text, View, findNodeHandle } 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
|
|
@@ -13,6 +15,7 @@ import { hashForId } from './catalogNavigation';
|
|
|
13
15
|
import {
|
|
14
16
|
alsoUsedBySegments,
|
|
15
17
|
anatomyColumns,
|
|
18
|
+
anatomyLayout,
|
|
16
19
|
anatomyPinFor,
|
|
17
20
|
ariaLevelFor,
|
|
18
21
|
buildPartTree,
|
|
@@ -51,6 +54,93 @@ function memberAsSectionDef(member: CatalogMember): SectionDef {
|
|
|
51
54
|
|
|
52
55
|
const ARIA_HIDDEN = { 'aria-hidden': true } as Record<string, unknown>;
|
|
53
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
|
+
|
|
54
144
|
/** Fidelity badge (spec §5.2 "Badges"): no existing fidelity/OS badge to reuse, so this is a small
|
|
55
145
|
* pill on `microLabel`, reusing `SectionBlock`'s own OS-badge visual. `replicated` keeps that
|
|
56
146
|
* plain pill; `approximated` and `documentation-only` both get the gray/muted treatment the
|
|
@@ -139,6 +229,8 @@ function BelowSpecimensLine({
|
|
|
139
229
|
source,
|
|
140
230
|
alsoUsedBy,
|
|
141
231
|
resolvesAddress,
|
|
232
|
+
navigation,
|
|
233
|
+
sourceMemberId,
|
|
142
234
|
}: {
|
|
143
235
|
related?: RelatedSegment[];
|
|
144
236
|
showRelationship: boolean;
|
|
@@ -146,33 +238,39 @@ function BelowSpecimensLine({
|
|
|
146
238
|
source: string;
|
|
147
239
|
alsoUsedBy: AlsoUsedByEntry[];
|
|
148
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;
|
|
149
245
|
}) {
|
|
150
246
|
const usedBy = alsoUsedBySegments(alsoUsedBy);
|
|
151
247
|
return (
|
|
152
248
|
<Text style={styles.belowLine}>
|
|
153
249
|
{related &&
|
|
154
|
-
related.map((segment, i) =>
|
|
155
|
-
segment.memberId ? (
|
|
156
|
-
|
|
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}>
|
|
157
254
|
{segment.text}
|
|
158
255
|
</Text>
|
|
159
256
|
) : (
|
|
160
257
|
<Text key={i}>{segment.text}</Text>
|
|
161
|
-
)
|
|
162
|
-
)}
|
|
258
|
+
);
|
|
259
|
+
})}
|
|
163
260
|
{related && ' · '}
|
|
164
261
|
{showRelationship && `${relationship} · `}
|
|
165
262
|
{source}
|
|
166
263
|
{usedBy.length > 0 && ' · '}
|
|
167
|
-
{usedBy.map((segment, i) =>
|
|
168
|
-
segment.component
|
|
169
|
-
|
|
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}>
|
|
170
268
|
{segment.text}
|
|
171
269
|
</Text>
|
|
172
270
|
) : (
|
|
173
271
|
<Text key={i}>{segment.text}</Text>
|
|
174
|
-
)
|
|
175
|
-
)}
|
|
272
|
+
);
|
|
273
|
+
})}
|
|
176
274
|
</Text>
|
|
177
275
|
);
|
|
178
276
|
}
|
|
@@ -186,12 +284,14 @@ function WholeBelowLine({
|
|
|
186
284
|
source,
|
|
187
285
|
alsoUsedBy,
|
|
188
286
|
resolvesAddress,
|
|
287
|
+
navigation,
|
|
189
288
|
}: {
|
|
190
289
|
name: string;
|
|
191
290
|
fidelity: CatalogMember['fidelity'];
|
|
192
291
|
source: string;
|
|
193
292
|
alsoUsedBy: AlsoUsedByEntry[];
|
|
194
293
|
resolvesAddress: (name: string) => boolean;
|
|
294
|
+
navigation?: ReferenceNavigation;
|
|
195
295
|
}) {
|
|
196
296
|
const usedBy = alsoUsedBySegments(alsoUsedBy);
|
|
197
297
|
return (
|
|
@@ -202,17 +302,20 @@ function WholeBelowLine({
|
|
|
202
302
|
<Text style={styles.belowLine}> · </Text>
|
|
203
303
|
<Text style={styles.belowLine}>{source}</Text>
|
|
204
304
|
{usedBy.length > 0 && <Text style={styles.belowLine}> · </Text>}
|
|
205
|
-
{usedBy.map((segment, i) =>
|
|
206
|
-
|
|
207
|
-
|
|
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}>
|
|
208
311
|
{segment.text}
|
|
209
312
|
</Text>
|
|
210
313
|
) : (
|
|
211
314
|
<Text key={i} style={styles.belowLine}>
|
|
212
315
|
{segment.text}
|
|
213
316
|
</Text>
|
|
214
|
-
)
|
|
215
|
-
)}
|
|
317
|
+
);
|
|
318
|
+
})}
|
|
216
319
|
</View>
|
|
217
320
|
);
|
|
218
321
|
}
|
|
@@ -221,23 +324,36 @@ function WholeBelowLine({
|
|
|
221
324
|
* the disclosure, after notes and before Guidance. A name links — the same accent treatment
|
|
222
325
|
* `BelowSpecimensLine`'s own `related` links use — when it resolves to a real catalog address (a
|
|
223
326
|
* page, member, or alias); otherwise it shows in `codeSmall`, since there's nothing to link to. */
|
|
224
|
-
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
|
+
}) {
|
|
225
338
|
return (
|
|
226
339
|
<Text style={styles.usesLine}>
|
|
227
340
|
{'Uses '}
|
|
228
|
-
{composedOf.map((entry, i) =>
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
})}
|
|
241
357
|
</Text>
|
|
242
358
|
);
|
|
243
359
|
}
|
|
@@ -252,11 +368,13 @@ function DisclosureBody({
|
|
|
252
368
|
parts,
|
|
253
369
|
resolvedProps,
|
|
254
370
|
resolvesAddress,
|
|
371
|
+
navigation,
|
|
255
372
|
}: {
|
|
256
373
|
member: CatalogMember;
|
|
257
374
|
parts: DisclosurePart[];
|
|
258
375
|
resolvedProps?: PropDef[];
|
|
259
376
|
resolvesAddress: (name: string) => boolean;
|
|
377
|
+
navigation?: ReferenceNavigation;
|
|
260
378
|
}) {
|
|
261
379
|
return (
|
|
262
380
|
<View style={styles.disclosureBody}>
|
|
@@ -267,7 +385,7 @@ function DisclosureBody({
|
|
|
267
385
|
{note}
|
|
268
386
|
</Text>
|
|
269
387
|
))}
|
|
270
|
-
{parts.includes('uses') && <UsesLine composedOf={member.composedOf!} resolvesAddress={resolvesAddress} />}
|
|
388
|
+
{parts.includes('uses') && <UsesLine composedOf={member.composedOf!} resolvesAddress={resolvesAddress} navigation={navigation} sourceMemberId={member.id} />}
|
|
271
389
|
{parts.includes('whenToUse') && (
|
|
272
390
|
<Text style={styles.disclosureText}>
|
|
273
391
|
<Text style={styles.disclosureLead}>Guidance </Text>
|
|
@@ -290,55 +408,186 @@ function DisclosureBody({
|
|
|
290
408
|
);
|
|
291
409
|
}
|
|
292
410
|
|
|
293
|
-
/**
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
|
|
297
|
-
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() {
|
|
298
415
|
return (
|
|
299
416
|
<Text {...ARIA_HIDDEN} style={styles.chevron}>
|
|
300
|
-
|
|
417
|
+
›
|
|
301
418
|
</Text>
|
|
302
419
|
);
|
|
303
420
|
}
|
|
304
421
|
|
|
305
|
-
/**
|
|
306
|
-
|
|
307
|
-
|
|
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. */
|
|
308
545
|
function MemberDisclosureToggle({
|
|
309
546
|
member,
|
|
310
547
|
apiLabel,
|
|
311
548
|
resolvesAddress,
|
|
549
|
+
navigation,
|
|
312
550
|
}: {
|
|
313
551
|
member: CatalogMember & { props?: PropDef[] };
|
|
314
552
|
apiLabel: string;
|
|
315
553
|
resolvesAddress: (name: string) => boolean;
|
|
554
|
+
navigation?: ReferenceNavigation;
|
|
316
555
|
}) {
|
|
317
|
-
const
|
|
556
|
+
const panel = useContext(MemberPanelContext);
|
|
318
557
|
const [focused, setFocused] = useState(false);
|
|
319
558
|
const parts = disclosureContent(member, member.props);
|
|
320
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
|
+
}
|
|
321
568
|
const label = disclosureLabel(member, apiLabel, parts.includes('props'));
|
|
569
|
+
const open = panel.activeId === member.id;
|
|
322
570
|
return (
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
>
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
</Pressable>
|
|
336
|
-
{open && <DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} />}
|
|
337
|
-
</>
|
|
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>
|
|
338
583
|
);
|
|
339
584
|
}
|
|
340
585
|
|
|
341
|
-
|
|
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[] };
|
|
342
591
|
|
|
343
592
|
interface MemberRenderProps {
|
|
344
593
|
member: FamilyMember;
|
|
@@ -353,6 +602,9 @@ interface MemberRenderProps {
|
|
|
353
602
|
/** V2d item 3: whether a name (a `composedOf` entry's `component`) resolves to a real catalog
|
|
354
603
|
* address — a page, member, or alias — so `UsesLine` knows which names to link. */
|
|
355
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;
|
|
356
608
|
// Pressable forwards its ref to its underlying `View` (react-native's own `Pressable.d.ts`), so
|
|
357
609
|
// a registered node is always a `View` instance whether it came from a block member's `View`
|
|
358
610
|
// heading target or a folded row's `Pressable`.
|
|
@@ -367,7 +619,13 @@ interface MemberRenderProps {
|
|
|
367
619
|
* Related member (no pin, no gutter) — otherwise the same header and body: name, description,
|
|
368
620
|
* specimens (through `Blocks`, when the member has any), the below-specimens line, and a
|
|
369
621
|
* closed-by-default disclosure. */
|
|
370
|
-
|
|
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 }) {
|
|
371
629
|
const surface = member.specimenSurface ?? DEFAULT_SPECIMEN_SURFACE;
|
|
372
630
|
const memberDef = useMemo(() => memberAsSectionDef(member), [member]);
|
|
373
631
|
const blocks = useMemo(
|
|
@@ -399,6 +657,7 @@ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths,
|
|
|
399
657
|
/>
|
|
400
658
|
</View>
|
|
401
659
|
</View>
|
|
660
|
+
{afterNameRow}
|
|
402
661
|
{/* D2 item A1: `partBody`'s own rhythm gap applies to every member, numbered or not — only
|
|
403
662
|
the gutter indent (`partBodyIndented`) is conditional — so a Related member (never
|
|
404
663
|
numbered) still gets the same spacing between its own description/specimens/below-line/
|
|
@@ -423,9 +682,11 @@ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths,
|
|
|
423
682
|
source={member.source}
|
|
424
683
|
alsoUsedBy={member.alsoUsedBy ?? []}
|
|
425
684
|
resolvesAddress={resolvesAddress}
|
|
685
|
+
navigation={navigation}
|
|
686
|
+
sourceMemberId={member.id}
|
|
426
687
|
/>
|
|
427
688
|
<View style={styles.disclosureSpacer}>
|
|
428
|
-
<MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
|
|
689
|
+
<MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} navigation={navigation} />
|
|
429
690
|
</View>
|
|
430
691
|
</View>
|
|
431
692
|
</View>
|
|
@@ -440,10 +701,9 @@ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths,
|
|
|
440
701
|
* show specimens (spec's v8 reference keeps every folded-row example documentation-only). Slice
|
|
441
702
|
* D1: no gutter either way — a folded row's own pin (when it has a number) sits inline with its
|
|
442
703
|
* name, never offsetting the row's body. */
|
|
443
|
-
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 }) {
|
|
444
705
|
const [focused, setFocused] = useState(false);
|
|
445
706
|
const related = member.related ? linkifyRelated(member.related, allMemberIds) : undefined;
|
|
446
|
-
const parts = disclosureContent(member, member.props);
|
|
447
707
|
return (
|
|
448
708
|
<View style={styles.foldedRow}>
|
|
449
709
|
<Pressable
|
|
@@ -474,10 +734,10 @@ function FoldedRow({ member, number, allMemberIds, resolvesAddress, open, onTogg
|
|
|
474
734
|
source={member.source}
|
|
475
735
|
alsoUsedBy={member.alsoUsedBy ?? []}
|
|
476
736
|
resolvesAddress={resolvesAddress}
|
|
737
|
+
navigation={navigation}
|
|
738
|
+
sourceMemberId={member.id}
|
|
477
739
|
/>
|
|
478
|
-
{
|
|
479
|
-
<DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} />
|
|
480
|
-
)}
|
|
740
|
+
<MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} navigation={navigation} />
|
|
481
741
|
</View>
|
|
482
742
|
)}
|
|
483
743
|
</View>
|
|
@@ -571,10 +831,75 @@ interface MeasurableNode {
|
|
|
571
831
|
measureLayout?: (relativeToNativeNode: number, onSuccess: (left: number, top: number, width: number, height: number) => void, onFail: () => void) => void;
|
|
572
832
|
}
|
|
573
833
|
|
|
834
|
+
/** A node with `focus` (every mounted web host node's real DOM method) — narrower than RN's own
|
|
835
|
+
* `Text`/`View` ref types, the same reason `MeasurableNode` above exists for `measureLayout`. */
|
|
836
|
+
interface FocusableNode {
|
|
837
|
+
focus?: (options?: { preventScroll?: boolean }) => void;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Anatomy-narrow-layout focus fix: lets a legend row (`AnatomyMemberRow`/`AnatomyOtherRow`)
|
|
841
|
+
* register its own focusable name link and report when it gains focus, without either of them
|
|
842
|
+
* needing to know *why* — `AnatomyBox` is the only thing that reads `legendNodesRef`, after a
|
|
843
|
+
* layout-mode or balanced-column change remounts the row that currently holds focus at a new
|
|
844
|
+
* tree position (switching `anatomyLegend` <-> `anatomyColumns`, or redistributing columns)
|
|
845
|
+
* disconnects it and the browser drops focus to `<body>`. `register`'s `key` is stable across
|
|
846
|
+
* that remount (a member id, or `other:<name>` for an "Other page" row), so the *replacement*
|
|
847
|
+
* node at the same address can be found and refocused. */
|
|
848
|
+
interface LegendFocus {
|
|
849
|
+
register: (key: string, node: FocusableNode | null) => void;
|
|
850
|
+
onFocus: (key: string) => void;
|
|
851
|
+
onBlur: (key: string) => void;
|
|
852
|
+
}
|
|
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
|
+
|
|
574
897
|
/** One Part in its tree position: rendered through `FamilyPart` as always, wrapped in a `Branch`
|
|
575
898
|
* only when it has children (a leaf costs nothing extra), with its children nested inside its own
|
|
576
|
-
* block (spec: "so the lines don't cross other parts") at one more 52px gutter.
|
|
577
|
-
|
|
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({
|
|
578
903
|
node,
|
|
579
904
|
byId,
|
|
580
905
|
outline,
|
|
@@ -582,8 +907,10 @@ function PartTreeBranch({
|
|
|
582
907
|
defaultPreviewWidths,
|
|
583
908
|
apiLabel,
|
|
584
909
|
resolvesAddress,
|
|
910
|
+
navigation,
|
|
585
911
|
registerNode,
|
|
586
912
|
pinNodesRef,
|
|
913
|
+
externalParts,
|
|
587
914
|
}: {
|
|
588
915
|
node: PartTreeEntry;
|
|
589
916
|
byId: ReadonlyMap<string, FamilyMember>;
|
|
@@ -592,12 +919,18 @@ function PartTreeBranch({
|
|
|
592
919
|
defaultPreviewWidths?: PreviewWidths;
|
|
593
920
|
apiLabel: string;
|
|
594
921
|
resolvesAddress: (name: string) => boolean;
|
|
922
|
+
navigation?: ReferenceNavigation;
|
|
595
923
|
registerNode: (id: string, node: View | null) => void;
|
|
596
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'];
|
|
597
931
|
}) {
|
|
598
932
|
const member = byId.get(node.id);
|
|
599
|
-
|
|
600
|
-
const part = (
|
|
933
|
+
const part = member ? (
|
|
601
934
|
<FamilyPart
|
|
602
935
|
member={member}
|
|
603
936
|
number={outline.get(node.id)?.number}
|
|
@@ -606,13 +939,20 @@ function PartTreeBranch({
|
|
|
606
939
|
defaultPreviewWidths={defaultPreviewWidths}
|
|
607
940
|
apiLabel={apiLabel}
|
|
608
941
|
resolvesAddress={resolvesAddress}
|
|
942
|
+
navigation={navigation}
|
|
609
943
|
registerNode={registerNode}
|
|
610
944
|
pinRef={(pinNode) => {
|
|
611
945
|
if (pinNode) pinNodesRef.current.set(node.id, pinNode);
|
|
612
946
|
else pinNodesRef.current.delete(node.id);
|
|
613
947
|
}}
|
|
614
948
|
/>
|
|
615
|
-
)
|
|
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;
|
|
616
956
|
if (node.children.length === 0) return part;
|
|
617
957
|
return (
|
|
618
958
|
<Branch parentId={node.id} childIds={node.children.map((child) => child.id)} pinNodesRef={pinNodesRef}>
|
|
@@ -628,8 +968,10 @@ function PartTreeBranch({
|
|
|
628
968
|
defaultPreviewWidths={defaultPreviewWidths}
|
|
629
969
|
apiLabel={apiLabel}
|
|
630
970
|
resolvesAddress={resolvesAddress}
|
|
971
|
+
navigation={navigation}
|
|
631
972
|
registerNode={registerNode}
|
|
632
973
|
pinNodesRef={pinNodesRef}
|
|
974
|
+
externalParts={externalParts}
|
|
633
975
|
/>
|
|
634
976
|
))}
|
|
635
977
|
</View>
|
|
@@ -648,9 +990,11 @@ function FamilySectionGroup({
|
|
|
648
990
|
defaultPreviewWidths,
|
|
649
991
|
apiLabel,
|
|
650
992
|
resolvesAddress,
|
|
993
|
+
navigation,
|
|
651
994
|
openRows,
|
|
652
995
|
onToggleRow,
|
|
653
996
|
registerNode,
|
|
997
|
+
externalParts,
|
|
654
998
|
}: {
|
|
655
999
|
section: CatalogMemberSection;
|
|
656
1000
|
members: FamilyMember[];
|
|
@@ -659,14 +1003,22 @@ function FamilySectionGroup({
|
|
|
659
1003
|
defaultPreviewWidths?: PreviewWidths;
|
|
660
1004
|
apiLabel: string;
|
|
661
1005
|
resolvesAddress: (name: string) => boolean;
|
|
1006
|
+
navigation?: ReferenceNavigation;
|
|
662
1007
|
openRows: ReadonlySet<string>;
|
|
663
1008
|
onToggleRow: (id: string) => void;
|
|
664
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'];
|
|
665
1013
|
}) {
|
|
666
1014
|
const byId = useMemo(() => new Map(members.map((member) => [member.id, member])), [members]);
|
|
667
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));
|
|
668
1020
|
if (section.kind === 'blocks') {
|
|
669
|
-
const tree = buildPartTree(
|
|
1021
|
+
const tree = buildPartTree(ids, outline);
|
|
670
1022
|
return (
|
|
671
1023
|
<View style={styles.blocksSection}>
|
|
672
1024
|
{tree.map((node) => (
|
|
@@ -679,8 +1031,10 @@ function FamilySectionGroup({
|
|
|
679
1031
|
defaultPreviewWidths={defaultPreviewWidths}
|
|
680
1032
|
apiLabel={apiLabel}
|
|
681
1033
|
resolvesAddress={resolvesAddress}
|
|
1034
|
+
navigation={navigation}
|
|
682
1035
|
registerNode={registerNode}
|
|
683
1036
|
pinNodesRef={pinNodesRef}
|
|
1037
|
+
externalParts={externalParts}
|
|
684
1038
|
/>
|
|
685
1039
|
))}
|
|
686
1040
|
</View>
|
|
@@ -688,21 +1042,30 @@ function FamilySectionGroup({
|
|
|
688
1042
|
}
|
|
689
1043
|
return (
|
|
690
1044
|
<View style={styles.foldedList}>
|
|
691
|
-
{
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
+
})}
|
|
706
1069
|
</View>
|
|
707
1070
|
);
|
|
708
1071
|
}
|
|
@@ -711,12 +1074,12 @@ function FamilySectionGroup({
|
|
|
711
1074
|
* page's own id as a link when it resolves to a real catalog address, plain text when not — never
|
|
712
1075
|
* a dead link — then its note, when authored. No specimens, no disclosure, no source line, no
|
|
713
1076
|
* number, no pin. */
|
|
714
|
-
function RelatedPageRow({ entry, resolvesAddress }: { entry: RelatedPage; resolvesAddress: (name: string) => boolean }) {
|
|
715
|
-
const
|
|
1077
|
+
function RelatedPageRow({ entry, resolvesAddress, navigation }: { entry: RelatedPage; resolvesAddress: (name: string) => boolean; navigation?: ReferenceNavigation }) {
|
|
1078
|
+
const link = familyLink(navigation, resolvesAddress, entry.page);
|
|
716
1079
|
return (
|
|
717
1080
|
<Text style={styles.belowLine}>
|
|
718
|
-
{
|
|
719
|
-
<Text role="link" style={styles.relatedPageName} {...
|
|
1081
|
+
{link ? (
|
|
1082
|
+
<Text role="link" style={styles.relatedPageName} {...link}>
|
|
720
1083
|
{entry.page}
|
|
721
1084
|
</Text>
|
|
722
1085
|
) : (
|
|
@@ -727,11 +1090,11 @@ function RelatedPageRow({ entry, resolvesAddress }: { entry: RelatedPage; resolv
|
|
|
727
1090
|
);
|
|
728
1091
|
}
|
|
729
1092
|
|
|
730
|
-
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 }) {
|
|
731
1094
|
return (
|
|
732
1095
|
<View style={styles.relatedPagesList}>
|
|
733
1096
|
{items.map((entry, i) => (
|
|
734
|
-
<RelatedPageRow key={i} entry={entry} resolvesAddress={resolvesAddress} />
|
|
1097
|
+
<RelatedPageRow key={i} entry={entry} resolvesAddress={resolvesAddress} navigation={navigation} />
|
|
735
1098
|
))}
|
|
736
1099
|
</View>
|
|
737
1100
|
);
|
|
@@ -753,9 +1116,11 @@ function FamilyLabeledGroup({
|
|
|
753
1116
|
defaultPreviewWidths,
|
|
754
1117
|
apiLabel,
|
|
755
1118
|
resolvesAddress,
|
|
1119
|
+
navigation,
|
|
756
1120
|
openRows,
|
|
757
1121
|
onToggleRow,
|
|
758
1122
|
registerNode,
|
|
1123
|
+
externalParts,
|
|
759
1124
|
}: {
|
|
760
1125
|
label: 'Parts' | 'Related';
|
|
761
1126
|
sections: CatalogMemberSection[];
|
|
@@ -767,9 +1132,13 @@ function FamilyLabeledGroup({
|
|
|
767
1132
|
defaultPreviewWidths?: PreviewWidths;
|
|
768
1133
|
apiLabel: string;
|
|
769
1134
|
resolvesAddress: (name: string) => boolean;
|
|
1135
|
+
navigation?: ReferenceNavigation;
|
|
770
1136
|
openRows: ReadonlySet<string>;
|
|
771
1137
|
onToggleRow: (id: string) => void;
|
|
772
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'];
|
|
773
1142
|
}) {
|
|
774
1143
|
// Field spec item 3: "a page with no relatedPages and no Related sections renders exactly as it
|
|
775
1144
|
// does today — no heading, no divider, nothing."
|
|
@@ -797,21 +1166,37 @@ function FamilyLabeledGroup({
|
|
|
797
1166
|
defaultPreviewWidths={defaultPreviewWidths}
|
|
798
1167
|
apiLabel={apiLabel}
|
|
799
1168
|
resolvesAddress={resolvesAddress}
|
|
1169
|
+
navigation={navigation}
|
|
800
1170
|
openRows={openRows}
|
|
801
1171
|
onToggleRow={onToggleRow}
|
|
802
1172
|
registerNode={registerNode}
|
|
1173
|
+
externalParts={externalParts}
|
|
803
1174
|
/>
|
|
804
1175
|
))}
|
|
805
|
-
{relatedPages && relatedPages.length > 0 && <RelatedPagesList items={relatedPages} resolvesAddress={resolvesAddress} />}
|
|
1176
|
+
{relatedPages && relatedPages.length > 0 && <RelatedPagesList items={relatedPages} resolvesAddress={resolvesAddress} navigation={navigation} />}
|
|
806
1177
|
</View>
|
|
807
1178
|
</View>
|
|
808
1179
|
);
|
|
809
1180
|
}
|
|
810
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
|
+
|
|
811
1196
|
/** Slice D1 item 5: the family page's own member records and computed outline, read by `AnatomyPin`
|
|
812
1197
|
* so a page's `anatomy.picture` (author-supplied markup, possibly arbitrarily positioned) never
|
|
813
1198
|
* hardcodes a number. `undefined` outside a family page's own anatomy box. */
|
|
814
|
-
const FamilyAnatomyContext = createContext<{ byId:
|
|
1199
|
+
const FamilyAnatomyContext = createContext<{ byId: PinMembers; outline: FamilyDef['outline'] } | undefined>(undefined);
|
|
815
1200
|
|
|
816
1201
|
/** Slice D1 item 5: a pin for use inside `anatomy.picture`. `member` names one of this page's
|
|
817
1202
|
* members and shows its number (dashed when it has no area of its own), looked up through
|
|
@@ -841,8 +1226,12 @@ export function AnatomyPin({ member, other }: { member?: string; other?: string
|
|
|
841
1226
|
* when there's a pin to show — a member with no number (outside every `inWhole` section) gets no
|
|
842
1227
|
* gutter either, so its name starts flush at the row's own left edge instead of under blank
|
|
843
1228
|
* space. */
|
|
844
|
-
function AnatomyMemberRow({ memberId, description, byId, outline }: { 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 }) {
|
|
845
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);
|
|
846
1235
|
return (
|
|
847
1236
|
<View style={styles.legendRow}>
|
|
848
1237
|
{pin && (
|
|
@@ -854,9 +1243,18 @@ function AnatomyMemberRow({ memberId, description, byId, outline }: { memberId:
|
|
|
854
1243
|
</View>
|
|
855
1244
|
)}
|
|
856
1245
|
<View style={styles.legendBody}>
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
+
)}
|
|
860
1258
|
<Text style={styles.legendDesc}>{description}</Text>
|
|
861
1259
|
</View>
|
|
862
1260
|
</View>
|
|
@@ -869,8 +1267,11 @@ function AnatomyMemberRow({ memberId, description, byId, outline }: { memberId:
|
|
|
869
1267
|
* "other page" row always shows its ↗, so the slot is never blank. Keeping it also keeps
|
|
870
1268
|
* APPROVED.md's "the description ... starts at the name's left edge" — pulling the marker inline
|
|
871
1269
|
* instead left each description hanging 26px to the left of its own name. */
|
|
872
|
-
function AnatomyOtherRow({ name, description, resolvesAddress }: { name: string; description: string; resolvesAddress: (name: string) => boolean }) {
|
|
873
|
-
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);
|
|
1272
|
+
// No member id of its own (it names a component documented elsewhere), so it needs its own
|
|
1273
|
+
// namespace to never collide with a real member id sharing the same text.
|
|
1274
|
+
const focusKey = `other:${name}`;
|
|
874
1275
|
return (
|
|
875
1276
|
<View style={styles.legendRow}>
|
|
876
1277
|
<View style={styles.legendPinSlot} {...ARIA_HIDDEN} testID="legend-pin-slot">
|
|
@@ -880,8 +1281,13 @@ function AnatomyOtherRow({ name, description, resolvesAddress }: { name: string;
|
|
|
880
1281
|
</View>
|
|
881
1282
|
<View style={styles.legendBody}>
|
|
882
1283
|
<View style={styles.legendNameRow}>
|
|
883
|
-
{
|
|
884
|
-
<Text
|
|
1284
|
+
{link ? (
|
|
1285
|
+
<Text
|
|
1286
|
+
role="link"
|
|
1287
|
+
style={styles.legendName}
|
|
1288
|
+
ref={(node) => legendFocus.register(focusKey, node as unknown as FocusableNode)}
|
|
1289
|
+
{...({ ...link, onFocus: () => legendFocus.onFocus(focusKey), onBlur: () => legendFocus.onBlur(focusKey) } as Record<string, unknown>)}
|
|
1290
|
+
>
|
|
885
1291
|
{name}
|
|
886
1292
|
</Text>
|
|
887
1293
|
) : (
|
|
@@ -895,69 +1301,200 @@ function AnatomyOtherRow({ name, description, resolvesAddress }: { name: string;
|
|
|
895
1301
|
);
|
|
896
1302
|
}
|
|
897
1303
|
|
|
898
|
-
function AnatomyGroup({ group, byId, outline, resolvesAddress }: { 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 }) {
|
|
899
1305
|
return (
|
|
900
1306
|
<View>
|
|
901
1307
|
<Text style={styles.legendGroupLabel}>{group.label}</Text>
|
|
902
1308
|
{group.items.map((item, i) =>
|
|
903
1309
|
'member' in item ? (
|
|
904
|
-
<AnatomyMemberRow key={i} memberId={item.member} description={item.description} byId={byId} outline={outline} />
|
|
1310
|
+
<AnatomyMemberRow key={i} memberId={item.member} description={item.description} byId={byId} outline={outline} legendFocus={legendFocus} resolvesAddress={resolvesAddress} navigation={navigation} />
|
|
905
1311
|
) : (
|
|
906
|
-
<AnatomyOtherRow key={i} name={item.other} description={item.description} resolvesAddress={resolvesAddress} />
|
|
1312
|
+
<AnatomyOtherRow key={i} name={item.other} description={item.description} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
|
|
907
1313
|
),
|
|
908
1314
|
)}
|
|
909
1315
|
</View>
|
|
910
1316
|
);
|
|
911
1317
|
}
|
|
912
1318
|
|
|
913
|
-
/** Slice D1 item 5: the anatomy box — always first (right after the page head),
|
|
914
|
-
* "Anatomy" label above the picture (the same label-outside-card treatment the
|
|
915
|
-
* below it use), and a legend beside it
|
|
916
|
-
*
|
|
917
|
-
|
|
1319
|
+
/** Slice D1 item 5, narrow-layout fix: the anatomy box — always first (right after the page head),
|
|
1320
|
+
* a plain-card "Anatomy" label above the picture (the same label-outside-card treatment the
|
|
1321
|
+
* specimen grids below it use), and a legend beside it or below it, decided by `anatomyLayout`
|
|
1322
|
+
* from the card's own measured width and the picture's own measured intrinsic width (web only —
|
|
1323
|
+
* see `onCardLayout`/`onPictureLayout` below), in up to three balanced columns when stacked.
|
|
1324
|
+
*
|
|
1325
|
+
* The stage (and the picture inside it) renders at the same position in this tree whether the
|
|
1326
|
+
* legend sits beside it or below it — only the wrapper's own `flexDirection` and the legend's own
|
|
1327
|
+
* shape change around it — so toggling between the two never remounts the picture and discards
|
|
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 }) {
|
|
918
1335
|
const totalItems = anatomy.groups.reduce((sum, group) => sum + group.items.length, 0);
|
|
919
|
-
const
|
|
1336
|
+
const [cardWidth, setCardWidth] = useState(0);
|
|
1337
|
+
const [pictureWidth, setPictureWidth] = useState(0);
|
|
1338
|
+
// Width-driven layout is a web-only concern (field spec: native keeps its existing item-count
|
|
1339
|
+
// decision) — `onLayout` itself is the same RN-compatible API either platform would use, but
|
|
1340
|
+
// these handlers simply never update state off web, so `anatomyLayout` always sees an
|
|
1341
|
+
// unmeasured (0) width on native and keeps falling back to the original `totalItems > 6` rule.
|
|
1342
|
+
// `onCardLayout` sits on the card's own interior wrapper (below), not the bordered card itself,
|
|
1343
|
+
// so the measured width is the interior content width `anatomyLayout`'s budget already assumes
|
|
1344
|
+
// — the bordered card's own box would overstate it by its own 2px (1px each side) border.
|
|
1345
|
+
const onCardLayout = (event: LayoutChangeEvent) => {
|
|
1346
|
+
if (Platform.OS !== 'web') return;
|
|
1347
|
+
const next = Math.round(event.nativeEvent.layout.width);
|
|
1348
|
+
if (next > 0 && next !== cardWidth) setCardWidth(next);
|
|
1349
|
+
};
|
|
1350
|
+
const onPictureLayout = (event: LayoutChangeEvent) => {
|
|
1351
|
+
if (Platform.OS !== 'web') return;
|
|
1352
|
+
const next = Math.round(event.nativeEvent.layout.width);
|
|
1353
|
+
if (next > 0 && next !== pictureWidth) setPictureWidth(next);
|
|
1354
|
+
};
|
|
1355
|
+
const { stacked, legendColumns } = anatomyLayout(totalItems, cardWidth, pictureWidth);
|
|
920
1356
|
const contextValue = useMemo(() => ({ byId, outline }), [byId, outline]);
|
|
1357
|
+
|
|
1358
|
+
// Focus fix: a legend row's own focusable name link, keyed by its stable address (a member id,
|
|
1359
|
+
// or `other:<name>`), registered by whichever row currently renders it — see `LegendFocus`'s own
|
|
1360
|
+
// comment above. Refs/plain mutable state, not React state: recording "who last had focus"
|
|
1361
|
+
// must never itself trigger a render.
|
|
1362
|
+
const legendNodesRef = useRef<Map<string, FocusableNode | null>>(new Map());
|
|
1363
|
+
const focusedLegendKeyRef = useRef<string | undefined>(undefined);
|
|
1364
|
+
const legendFocus: LegendFocus = useMemo(
|
|
1365
|
+
() => ({
|
|
1366
|
+
register: (key, node) => {
|
|
1367
|
+
if (node) legendNodesRef.current.set(key, node);
|
|
1368
|
+
else legendNodesRef.current.delete(key);
|
|
1369
|
+
},
|
|
1370
|
+
onFocus: (key) => {
|
|
1371
|
+
focusedLegendKeyRef.current = key;
|
|
1372
|
+
},
|
|
1373
|
+
onBlur: (key) => {
|
|
1374
|
+
if (focusedLegendKeyRef.current === key) focusedLegendKeyRef.current = undefined;
|
|
1375
|
+
},
|
|
1376
|
+
}),
|
|
1377
|
+
[],
|
|
1378
|
+
);
|
|
1379
|
+
// A layout-mode switch (`anatomyLegend` <-> `anatomyColumns`) or a balanced-column count change
|
|
1380
|
+
// can remount the legend row that currently holds focus at a different tree position — the
|
|
1381
|
+
// browser then drops focus to `<body>` (web's own behavior for a removed focused node). Restore
|
|
1382
|
+
// it to the replacement row at the same address, right after that same render commits, the same
|
|
1383
|
+
// web-only `.focus()` convention `scrollAndFocusMember` already uses below. Guarded on
|
|
1384
|
+
// `document.activeElement === document.body` so another control's focus is never overridden.
|
|
1385
|
+
// Explicit blur clears the recorded key, so clicking page background cannot revive stale focus;
|
|
1386
|
+
// disconnecting an active anchor during reflow does not dispatch that blur event.
|
|
1387
|
+
useLayoutEffect(() => {
|
|
1388
|
+
if (Platform.OS !== 'web') return;
|
|
1389
|
+
const key = focusedLegendKeyRef.current;
|
|
1390
|
+
if (!key) return;
|
|
1391
|
+
if (document.activeElement !== document.body) return;
|
|
1392
|
+
legendNodesRef.current.get(key)?.focus?.({ preventScroll: true });
|
|
1393
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1394
|
+
}, [stacked, legendColumns]);
|
|
1395
|
+
|
|
1396
|
+
const stage = (
|
|
1397
|
+
<View style={styles.anatomyStage}>
|
|
1398
|
+
{/* Measures the picture's own intrinsic width, not the stage: the stage stretches to the
|
|
1399
|
+
card's full width when stacked (a plain View's default cross-axis stretch in a column
|
|
1400
|
+
layout), but this wrapper's own `alignItems: 'center'` parent never stretches it in turn,
|
|
1401
|
+
so the picture's measured width stays the same whichever layout is in effect. Web only:
|
|
1402
|
+
this measuring wrapper would otherwise give an authored picture (e.g. one already using
|
|
1403
|
+
`alignSelf: 'stretch'` or percentage sizing) a new containing block it never had before —
|
|
1404
|
+
native keeps receiving `anatomy.picture` directly, as it always did. */}
|
|
1405
|
+
{Platform.OS === 'web' ? (
|
|
1406
|
+
// `testID` exists only so a renderer test can trigger a real resize on exactly this node
|
|
1407
|
+
// without depending on the author's own picture markup — see `legendPinSlot`'s comment.
|
|
1408
|
+
<View onLayout={onPictureLayout} testID="anatomy-picture-measure">
|
|
1409
|
+
{anatomy.picture}
|
|
1410
|
+
</View>
|
|
1411
|
+
) : (
|
|
1412
|
+
anatomy.picture
|
|
1413
|
+
)}
|
|
1414
|
+
</View>
|
|
1415
|
+
);
|
|
1416
|
+
|
|
921
1417
|
return (
|
|
922
1418
|
<FamilyAnatomyContext.Provider value={contextValue}>
|
|
923
1419
|
<View style={styles.anatomy}>
|
|
924
1420
|
<Text style={styles.anatomyHeadText}>Anatomy</Text>
|
|
925
1421
|
<View style={styles.anatomyCard}>
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
<View style={styles.
|
|
930
|
-
{anatomyColumns(anatomy.groups, 3).map((column, i) => (
|
|
931
|
-
<View key={i} style={styles.anatomyColumn}>
|
|
932
|
-
{column.map((group, j) => (
|
|
933
|
-
<AnatomyGroup key={j} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} />
|
|
934
|
-
))}
|
|
935
|
-
</View>
|
|
936
|
-
))}
|
|
937
|
-
</View>
|
|
938
|
-
</View>
|
|
939
|
-
) : (
|
|
940
|
-
<View style={styles.anatomyBody}>
|
|
941
|
-
<View style={styles.anatomyStage}>{anatomy.picture}</View>
|
|
942
|
-
<View style={styles.anatomyLegend}>
|
|
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">
|
|
943
1426
|
{anatomy.groups.map((group, i) => (
|
|
944
|
-
<AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} />
|
|
1427
|
+
<AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
|
|
945
1428
|
))}
|
|
946
1429
|
</View>
|
|
1430
|
+
) : (
|
|
1431
|
+
<View style={stacked ? undefined : styles.anatomyBody} onLayout={onCardLayout} testID="anatomy-body">
|
|
1432
|
+
{stage}
|
|
1433
|
+
{stacked ? (
|
|
1434
|
+
// `testID` exists only so a renderer test can assert which layout rendered without
|
|
1435
|
+
// depending on geometry jsdom can't provide (no ResizeObserver there — `onLayout`
|
|
1436
|
+
// never fires), the same reason `legendPinSlot` carries one above.
|
|
1437
|
+
<View style={styles.anatomyColumns} testID="anatomy-columns">
|
|
1438
|
+
{anatomyColumns(anatomy.groups, legendColumns).map((column, i) => (
|
|
1439
|
+
<View key={i} style={styles.anatomyColumn}>
|
|
1440
|
+
{column.map((group, j) => (
|
|
1441
|
+
<AnatomyGroup key={j} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
|
|
1442
|
+
))}
|
|
1443
|
+
</View>
|
|
1444
|
+
))}
|
|
1445
|
+
</View>
|
|
1446
|
+
) : (
|
|
1447
|
+
<View style={styles.anatomyLegend} testID="anatomy-legend">
|
|
1448
|
+
{anatomy.groups.map((group, i) => (
|
|
1449
|
+
<AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} navigation={navigation} legendFocus={legendFocus} />
|
|
1450
|
+
))}
|
|
1451
|
+
</View>
|
|
1452
|
+
)}
|
|
947
1453
|
</View>
|
|
948
|
-
|
|
1454
|
+
)}
|
|
949
1455
|
</View>
|
|
950
1456
|
</View>
|
|
951
1457
|
</FamilyAnatomyContext.Provider>
|
|
952
1458
|
);
|
|
953
1459
|
}
|
|
954
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
|
+
|
|
955
1489
|
/** Scrolls a member's node into view and moves focus to it (spec §5.3 "Member address"). The jump
|
|
956
1490
|
* is always instant, like a normal anchor link: a smooth `scrollIntoView` does not reliably move
|
|
957
1491
|
* the shell's nested scroll container on web (the same limitation `CatalogShell`'s
|
|
958
1492
|
* `resetMainScroll` works around), and an instant jump also satisfies Reduce Motion. Native has
|
|
959
|
-
* no scroll-into-view of its own; `AccessibilityInfo`'s focus call is enough there.
|
|
960
|
-
|
|
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 {
|
|
961
1498
|
if (Platform.OS === 'web') {
|
|
962
1499
|
const dom = node as unknown as { scrollIntoView?: (options: { behavior: 'auto'; block: 'start' }) => void; focus?: (options?: { preventScroll?: boolean }) => void };
|
|
963
1500
|
dom.scrollIntoView?.({ behavior: 'auto', block: 'start' });
|
|
@@ -986,6 +1523,7 @@ export function FamilyPage<TId extends string>({
|
|
|
986
1523
|
focusMemberId,
|
|
987
1524
|
focusNonce,
|
|
988
1525
|
knownAddressIds,
|
|
1526
|
+
navigation,
|
|
989
1527
|
}: {
|
|
990
1528
|
def: SectionDef<TId>;
|
|
991
1529
|
groupLabel?: string;
|
|
@@ -1011,10 +1549,19 @@ export function FamilyPage<TId extends string>({
|
|
|
1011
1549
|
* omitted when `FamilyPage` is used standalone (e.g. this very page's own live demo), where
|
|
1012
1550
|
* only this family's own members can resolve. */
|
|
1013
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>;
|
|
1014
1560
|
}) {
|
|
1015
1561
|
const family = def.family!;
|
|
1016
1562
|
const apiLabel = family.apiLabel ?? 'API';
|
|
1017
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]);
|
|
1018
1565
|
// N7: a `whole` that isn't one of its own members (bad data, already reported by
|
|
1019
1566
|
// `validateFamily`) must degrade to a missing block, never a crash reading an absent member.
|
|
1020
1567
|
const wholeMember = resolveWhole(byId, family.whole);
|
|
@@ -1071,28 +1618,11 @@ export function FamilyPage<TId extends string>({
|
|
|
1071
1618
|
}, [focusMemberId, openRows, focusNonce]);
|
|
1072
1619
|
|
|
1073
1620
|
return (
|
|
1621
|
+
<MemberPanelHost>
|
|
1074
1622
|
<View>
|
|
1075
|
-
|
|
1076
|
-
{banner}
|
|
1077
|
-
<View style={styles.pageHead}>
|
|
1078
|
-
<View style={styles.titlebar}>
|
|
1079
|
-
<View style={styles.titleText}>
|
|
1080
|
-
<View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
|
|
1081
|
-
<Text style={styles.title}>{def.title ?? def.id}</Text>
|
|
1082
|
-
</View>
|
|
1083
|
-
<Text style={styles.desc}>{def.description}</Text>
|
|
1084
|
-
{family.notShown && (
|
|
1085
|
-
<Text style={styles.notShown}>
|
|
1086
|
-
<Text style={styles.notShownLead}>Not shown in the browser: </Text>
|
|
1087
|
-
{family.notShown}
|
|
1088
|
-
</Text>
|
|
1089
|
-
)}
|
|
1090
|
-
</View>
|
|
1091
|
-
{pager && <SectionPagerRow pager={pager} />}
|
|
1092
|
-
</View>
|
|
1093
|
-
</View>
|
|
1623
|
+
<FamilyPageHead kind="family" def={def} groupLabel={groupLabel} breadcrumbRoot={breadcrumbRoot} banner={banner} pager={pager} headingRef={headingRef} notShown={family.notShown} />
|
|
1094
1624
|
|
|
1095
|
-
{family.anatomy && <AnatomyBox anatomy={family.anatomy} byId={
|
|
1625
|
+
{family.anatomy && <AnatomyBox anatomy={family.anatomy} byId={anatomyById} outline={family.outline} resolvesAddress={resolvesAddress} navigation={navigation} />}
|
|
1096
1626
|
|
|
1097
1627
|
{wholeMember && (
|
|
1098
1628
|
<View style={styles.whole} ref={(node) => registerNode(wholeMember.id, node)} tabIndex={-1}>
|
|
@@ -1114,9 +1644,10 @@ export function FamilyPage<TId extends string>({
|
|
|
1114
1644
|
source={wholeMember.source}
|
|
1115
1645
|
alsoUsedBy={wholeMember.alsoUsedBy ?? []}
|
|
1116
1646
|
resolvesAddress={resolvesAddress}
|
|
1647
|
+
navigation={navigation}
|
|
1117
1648
|
/>
|
|
1118
1649
|
<View style={styles.disclosureSpacer}>
|
|
1119
|
-
<MemberDisclosureToggle member={wholeMember} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
|
|
1650
|
+
<MemberDisclosureToggle member={wholeMember} apiLabel={apiLabel} resolvesAddress={resolvesAddress} navigation={navigation} />
|
|
1120
1651
|
</View>
|
|
1121
1652
|
</>
|
|
1122
1653
|
);
|
|
@@ -1133,9 +1664,11 @@ export function FamilyPage<TId extends string>({
|
|
|
1133
1664
|
defaultPreviewWidths={defaultPreviewWidths}
|
|
1134
1665
|
apiLabel={apiLabel}
|
|
1135
1666
|
resolvesAddress={resolvesAddress}
|
|
1667
|
+
navigation={navigation}
|
|
1136
1668
|
openRows={openRows}
|
|
1137
1669
|
onToggleRow={toggleRow}
|
|
1138
1670
|
registerNode={registerNode}
|
|
1671
|
+
externalParts={family.externalParts}
|
|
1139
1672
|
/>
|
|
1140
1673
|
<FamilyLabeledGroup
|
|
1141
1674
|
label="Related"
|
|
@@ -1147,11 +1680,13 @@ export function FamilyPage<TId extends string>({
|
|
|
1147
1680
|
defaultPreviewWidths={defaultPreviewWidths}
|
|
1148
1681
|
apiLabel={apiLabel}
|
|
1149
1682
|
resolvesAddress={resolvesAddress}
|
|
1683
|
+
navigation={navigation}
|
|
1150
1684
|
openRows={openRows}
|
|
1151
1685
|
onToggleRow={toggleRow}
|
|
1152
1686
|
registerNode={registerNode}
|
|
1153
1687
|
/>
|
|
1154
1688
|
</View>
|
|
1689
|
+
</MemberPanelHost>
|
|
1155
1690
|
);
|
|
1156
1691
|
}
|
|
1157
1692
|
|
|
@@ -1168,6 +1703,9 @@ const styles = StyleSheet.create({
|
|
|
1168
1703
|
title: { ...CATALOG_TEXT.pageTitle, color: CATALOG_COLOR.text, marginBottom: 5 },
|
|
1169
1704
|
desc: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
|
|
1170
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 },
|
|
1171
1709
|
notShownLead: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
|
|
1172
1710
|
|
|
1173
1711
|
// Slice D1 item 5: the anatomy box's own "Anatomy" label sits outside, above a plain card —
|
|
@@ -1179,6 +1717,7 @@ const styles = StyleSheet.create({
|
|
|
1179
1717
|
anatomyBody: { flexDirection: 'row', alignItems: 'flex-start' },
|
|
1180
1718
|
anatomyStage: { padding: CATALOG_SPACE.xl, alignItems: 'center', justifyContent: 'center' },
|
|
1181
1719
|
anatomyLegend: { flex: 1, borderLeftWidth: 1, borderLeftColor: CATALOG_COLOR.border, paddingVertical: CATALOG_SPACE.sm },
|
|
1720
|
+
anatomyLegendAlone: { paddingVertical: CATALOG_SPACE.sm },
|
|
1182
1721
|
anatomyColumns: { flexDirection: 'row', borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
|
|
1183
1722
|
anatomyColumn: { flex: 1, borderLeftWidth: 1, borderLeftColor: CATALOG_COLOR.border, paddingVertical: CATALOG_SPACE.sm },
|
|
1184
1723
|
// Host-authored text (`anatomy.groups[].label`) — never a transforming role like `microLabel`,
|
|
@@ -1283,6 +1822,16 @@ const styles = StyleSheet.create({
|
|
|
1283
1822
|
justifyContent: 'center',
|
|
1284
1823
|
},
|
|
1285
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 },
|
|
1286
1835
|
disclosureToggleLabel: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.accent },
|
|
1287
1836
|
disclosureBody: { gap: CATALOG_SPACE.sm, paddingTop: CATALOG_SPACE.xs },
|
|
1288
1837
|
note: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.text },
|