@krapwoo/ds-viewer 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/dist/cli/doctor.js +182 -3
- package/dist/cli/staticPage.js +129 -0
- package/dist/native/catalog/catalogNavigation.js +62 -3
- package/dist/native/catalog/comparison.js +14 -3
- package/dist/native/catalog/pageApi.js +408 -0
- package/dist/native/catalog/tokens.js +67 -12
- package/native/catalog/BoundedOverlayViewport.tsx +2 -2
- package/native/catalog/CatalogButton.tsx +2 -2
- package/native/catalog/CatalogSearchInput.tsx +3 -3
- package/native/catalog/CatalogShell.tsx +213 -32
- package/native/catalog/CatalogSidebar.tsx +69 -19
- package/native/catalog/ComparisonGrid.tsx +4 -4
- package/native/catalog/ComparisonGroups.tsx +3 -3
- package/native/catalog/ComparisonList.tsx +2 -2
- package/native/catalog/FamilyPage.tsx +1188 -0
- package/native/catalog/MotionSpecimen.tsx +3 -3
- package/native/catalog/PhoneFrame.tsx +2 -2
- package/native/catalog/PropsTable.tsx +7 -7
- package/native/catalog/ReferenceDetails.tsx +33 -22
- package/native/catalog/SectionBlock.tsx +57 -27
- package/native/catalog/SpacingScaleGallery.tsx +3 -3
- package/native/catalog/Swatch.tsx +3 -3
- package/native/catalog/TokenLayouts.tsx +6 -9
- package/native/catalog/TokenRow.tsx +2 -2
- package/native/catalog/TypeScaleGallery.tsx +2 -2
- package/native/catalog/UpdatePanel.tsx +29 -30
- package/native/catalog/VariantGroup.tsx +3 -6
- package/native/catalog/catalogNavigation.ts +89 -3
- package/native/catalog/comparison.ts +17 -6
- package/native/catalog/familyModel.ts +192 -0
- package/native/catalog/index.ts +8 -2
- package/native/catalog/overlayViewport.ts +30 -10
- package/native/catalog/pageApi.ts +357 -15
- package/native/catalog/tokens.ts +70 -12
- package/native/catalog/types.ts +106 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { AccessibilityInfo, Platform, Pressable, StyleSheet, Text, View, findNodeHandle } from 'react-native';
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
|
|
4
|
+
import { Blocks, SectionPagerRow, type SectionPager } from './SectionBlock';
|
|
5
|
+
import { PropsTable } from './PropsTable';
|
|
6
|
+
import { presentationBlocks } from './comparison';
|
|
7
|
+
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
8
|
+
import { hashForId } from './catalogNavigation';
|
|
9
|
+
// A plain extensionless import — the same one every other `.tsx` file here already uses for its
|
|
10
|
+
// own pure-module siblings (e.g. `CatalogShell.tsx` → `./catalogNavigation`): this file is a React
|
|
11
|
+
// component with no test of its own that imports it directly under Node, so it only ever goes
|
|
12
|
+
// through the bundler.
|
|
13
|
+
import {
|
|
14
|
+
anatomyColumns,
|
|
15
|
+
anatomyPinFor,
|
|
16
|
+
ariaLevelFor,
|
|
17
|
+
buildPartTree,
|
|
18
|
+
disclosureContent,
|
|
19
|
+
disclosureLabel,
|
|
20
|
+
linkifyRelated,
|
|
21
|
+
resolveWhole,
|
|
22
|
+
sectionFor,
|
|
23
|
+
shouldRunMemberJump,
|
|
24
|
+
type DisclosurePart,
|
|
25
|
+
type MemberJumpState,
|
|
26
|
+
type PartTreeEntry,
|
|
27
|
+
type RelatedSegment,
|
|
28
|
+
} from './familyModel';
|
|
29
|
+
import type { CatalogAnatomy, CatalogAnatomyGroup, CatalogMember, CatalogMemberSection, ComposedOfEntry, FamilyDef, PreviewWidths, PropDef, SectionDef } from './types';
|
|
30
|
+
|
|
31
|
+
/** `SectionDef`'s parts `presentationBlocks`/`Blocks` actually read, built from one family member
|
|
32
|
+
* so its own specimens render through the exact same renderers (`VariantGroup`, `ComparisonGrid`,
|
|
33
|
+
* `ComparisonList`, `SpecimenSurface`) a normal page's do, with no second copy of that logic (spec
|
|
34
|
+
* §5.2 "Member block"). A member's `hide` only ever hides `accessibility`/`props` (never
|
|
35
|
+
* `variants`/`states`), so this never sets `hide` — a member's specimens are never hidden by it. */
|
|
36
|
+
function memberAsSectionDef(member: CatalogMember): SectionDef {
|
|
37
|
+
return {
|
|
38
|
+
id: member.id,
|
|
39
|
+
description: member.description,
|
|
40
|
+
path: member.source,
|
|
41
|
+
variants: member.variants,
|
|
42
|
+
states: member.states,
|
|
43
|
+
comparison: member.comparison,
|
|
44
|
+
specimenSize: member.specimenSize,
|
|
45
|
+
specimenSurface: member.specimenSurface,
|
|
46
|
+
previewWidths: member.previewWidths,
|
|
47
|
+
previewLayout: member.previewLayout,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ARIA_HIDDEN = { 'aria-hidden': true } as Record<string, unknown>;
|
|
52
|
+
|
|
53
|
+
/** Fidelity badge (spec §5.2 "Badges"): no existing fidelity/OS badge to reuse, so this is a small
|
|
54
|
+
* pill on `microLabel`, reusing `SectionBlock`'s own OS-badge visual. `replicated` keeps that
|
|
55
|
+
* plain pill; `approximated` and `documentation-only` both get the gray/muted treatment the
|
|
56
|
+
* approved v8 bytes use for both (its final `.fidelity-approximated`/`.fidelity-doc-only` rules
|
|
57
|
+
* are identical). */
|
|
58
|
+
function FidelityBadge({ fidelity }: { fidelity: CatalogMember['fidelity'] }) {
|
|
59
|
+
const muted = fidelity !== 'replicated';
|
|
60
|
+
const label = fidelity === 'documentation-only' ? 'Documentation only' : fidelity === 'approximated' ? 'Approximated' : 'Replicated';
|
|
61
|
+
return (
|
|
62
|
+
<View style={[styles.badge, muted && styles.badgeMuted]}>
|
|
63
|
+
<Text style={[styles.badgeText, muted && styles.badgeTextMuted]}>{label}</Text>
|
|
64
|
+
</View>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** V2c item 8: a member's own OS-component marker, passed through to the member header — reuses
|
|
69
|
+
* `SectionBlock`'s own badge visual (`osBadge`/`osBadgeText`) so a member documented as a native
|
|
70
|
+
* control reads the same way a whole page's does. */
|
|
71
|
+
function OsBadge({ osComponent }: { osComponent: NonNullable<CatalogMember['osComponent']> }) {
|
|
72
|
+
return (
|
|
73
|
+
<View style={styles.osBadge}>
|
|
74
|
+
<Text style={styles.osBadgeText}>{osComponent === 'full' ? 'OS component' : 'Partly OS component'}</Text>
|
|
75
|
+
</View>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Slice D1 item 7: a numbered pin, a filled accent pill — used both by a Part's own header and by
|
|
80
|
+
* the anatomy legend/picture (`AnatomyPin`, which also allows the dashed, no-area-of-their-own
|
|
81
|
+
* style spec §"Pins" describes). Decorative: the member's own name (not the number) is what
|
|
82
|
+
* assistive tech announces. */
|
|
83
|
+
function Pin({ number, dashed }: { number: string; dashed?: boolean }) {
|
|
84
|
+
return (
|
|
85
|
+
<View style={[styles.pin, dashed && styles.pinDashed]} {...ARIA_HIDDEN}>
|
|
86
|
+
<Text style={[styles.pinText, dashed && styles.pinTextDashed]}>{number}</Text>
|
|
87
|
+
</View>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Slice D1 items 7–8: the name + badges row. Every part uses the same "18/24 bold" role
|
|
92
|
+
* regardless of nesting depth (item 10 removes the old per-depth header ladder) — only
|
|
93
|
+
* `aria-level` still reflects the member's real nesting depth, so assistive tech keeps an
|
|
94
|
+
* accurate outline even though the visual size no longer shrinks with it. */
|
|
95
|
+
function MemberNameRow({
|
|
96
|
+
name,
|
|
97
|
+
fidelity,
|
|
98
|
+
osComponent,
|
|
99
|
+
heading,
|
|
100
|
+
ariaLevel,
|
|
101
|
+
headingRef,
|
|
102
|
+
}: {
|
|
103
|
+
name: string;
|
|
104
|
+
fidelity: CatalogMember['fidelity'];
|
|
105
|
+
osComponent?: CatalogMember['osComponent'];
|
|
106
|
+
/** Whether this name sits inside a real heading (a block member) or inert text inside a folded
|
|
107
|
+
* row's own toggle button. */
|
|
108
|
+
heading: boolean;
|
|
109
|
+
ariaLevel?: number;
|
|
110
|
+
headingRef?: React.Ref<View>;
|
|
111
|
+
}) {
|
|
112
|
+
const nameNode = <Text style={[CATALOG_TEXT.heading2, styles.memberName]}>{name}</Text>;
|
|
113
|
+
return (
|
|
114
|
+
<View style={styles.memberHead}>
|
|
115
|
+
{heading ? (
|
|
116
|
+
<View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': ariaLevel } as Record<string, unknown>)} style={styles.headingTarget}>
|
|
117
|
+
{nameNode}
|
|
118
|
+
</View>
|
|
119
|
+
) : (
|
|
120
|
+
nameNode
|
|
121
|
+
)}
|
|
122
|
+
{osComponent && <OsBadge osComponent={osComponent} />}
|
|
123
|
+
<FidelityBadge fidelity={fidelity} />
|
|
124
|
+
</View>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `[related ·] [relationship ·] source path:line` (spec §5.2 "Below the specimens"). A member
|
|
129
|
+
* name inside `related` is a link to that member's address — a real `href`, like every other
|
|
130
|
+
* in-app link, so clicking it goes through the same `hashchange` routing as typing the address or
|
|
131
|
+
* using search (`CatalogShell`). */
|
|
132
|
+
function BelowSpecimensLine({
|
|
133
|
+
related,
|
|
134
|
+
showRelationship,
|
|
135
|
+
relationship,
|
|
136
|
+
source,
|
|
137
|
+
}: {
|
|
138
|
+
related?: RelatedSegment[];
|
|
139
|
+
showRelationship: boolean;
|
|
140
|
+
relationship: string;
|
|
141
|
+
source: string;
|
|
142
|
+
}) {
|
|
143
|
+
return (
|
|
144
|
+
<Text style={styles.belowLine}>
|
|
145
|
+
{related &&
|
|
146
|
+
related.map((segment, i) =>
|
|
147
|
+
segment.memberId ? (
|
|
148
|
+
<Text key={i} role="link" style={styles.relatedLink} {...({ href: hashForId(segment.memberId) } as Record<string, unknown>)}>
|
|
149
|
+
{segment.text}
|
|
150
|
+
</Text>
|
|
151
|
+
) : (
|
|
152
|
+
<Text key={i}>{segment.text}</Text>
|
|
153
|
+
),
|
|
154
|
+
)}
|
|
155
|
+
{related && ' · '}
|
|
156
|
+
{showRelationship && `${relationship} · `}
|
|
157
|
+
{source}
|
|
158
|
+
</Text>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Slice D1 item 6: the whole's own one-line summary — `<name> · <fidelity badge> · <source>` — in
|
|
163
|
+
* place of the below-specimens line (the whole has no `relationship`/`related` to show). */
|
|
164
|
+
function WholeBelowLine({ name, fidelity, source }: { name: string; fidelity: CatalogMember['fidelity']; source: string }) {
|
|
165
|
+
return (
|
|
166
|
+
<View style={styles.wholeBelowRow}>
|
|
167
|
+
<Text style={styles.wholeName}>{name}</Text>
|
|
168
|
+
<Text style={styles.belowLine}> · </Text>
|
|
169
|
+
<FidelityBadge fidelity={fidelity} />
|
|
170
|
+
<Text style={styles.belowLine}> · </Text>
|
|
171
|
+
<Text style={styles.belowLine}>{source}</Text>
|
|
172
|
+
</View>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** "Uses X (role), Y (role2)" (v8's own `.uses` CSS class; V2d item 3): one caption line inside
|
|
177
|
+
* the disclosure, after notes and before Guidance. A name links — the same accent treatment
|
|
178
|
+
* `BelowSpecimensLine`'s own `related` links use — when it resolves to a real catalog address (a
|
|
179
|
+
* page, member, or alias); otherwise it shows in `codeSmall`, since there's nothing to link to. */
|
|
180
|
+
function UsesLine({ composedOf, resolvesAddress }: { composedOf: ComposedOfEntry[]; resolvesAddress: (name: string) => boolean }) {
|
|
181
|
+
return (
|
|
182
|
+
<Text style={styles.usesLine}>
|
|
183
|
+
{'Uses '}
|
|
184
|
+
{composedOf.map((entry, i) => (
|
|
185
|
+
<Text key={i}>
|
|
186
|
+
{resolvesAddress(entry.component) ? (
|
|
187
|
+
<Text role="link" style={styles.relatedLink} {...({ href: hashForId(entry.component) } as Record<string, unknown>)}>
|
|
188
|
+
{entry.component}
|
|
189
|
+
</Text>
|
|
190
|
+
) : (
|
|
191
|
+
<Text style={styles.usesCode}>{entry.component}</Text>
|
|
192
|
+
)}
|
|
193
|
+
{` (${entry.role})`}
|
|
194
|
+
{i < composedOf.length - 1 ? ', ' : ''}
|
|
195
|
+
</Text>
|
|
196
|
+
))}
|
|
197
|
+
</Text>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The disclosure's body (spec §5.2 "Disclosure"): notes, then Uses (V2d item 2), then Guidance,
|
|
202
|
+
* then Accessibility, then generated Props (through the existing `PropsTable`), then the Swift
|
|
203
|
+
* excerpt — in the order `disclosureContent` already decided; this only ever renders the parts it
|
|
204
|
+
* lists. V2b item 10: the Guidance/Accessibility paragraphs sit under the same short
|
|
205
|
+
* `captionStrong` leads `ReferenceDetails.tsx` already uses for `whenToUse`/`a11y`. */
|
|
206
|
+
function DisclosureBody({
|
|
207
|
+
member,
|
|
208
|
+
parts,
|
|
209
|
+
resolvedProps,
|
|
210
|
+
resolvesAddress,
|
|
211
|
+
}: {
|
|
212
|
+
member: CatalogMember;
|
|
213
|
+
parts: DisclosurePart[];
|
|
214
|
+
resolvedProps?: PropDef[];
|
|
215
|
+
resolvesAddress: (name: string) => boolean;
|
|
216
|
+
}) {
|
|
217
|
+
return (
|
|
218
|
+
<View style={styles.disclosureBody}>
|
|
219
|
+
{parts.includes('notes') &&
|
|
220
|
+
member.notes!.map((note, i) => (
|
|
221
|
+
<Text key={i} style={styles.note}>
|
|
222
|
+
{'• '}
|
|
223
|
+
{note}
|
|
224
|
+
</Text>
|
|
225
|
+
))}
|
|
226
|
+
{parts.includes('uses') && <UsesLine composedOf={member.composedOf!} resolvesAddress={resolvesAddress} />}
|
|
227
|
+
{parts.includes('whenToUse') && (
|
|
228
|
+
<Text style={styles.disclosureText}>
|
|
229
|
+
<Text style={styles.disclosureLead}>Guidance </Text>
|
|
230
|
+
{member.whenToUse}
|
|
231
|
+
</Text>
|
|
232
|
+
)}
|
|
233
|
+
{parts.includes('a11y') && (
|
|
234
|
+
<Text style={styles.disclosureText}>
|
|
235
|
+
<Text style={styles.disclosureLead}>Accessibility </Text>
|
|
236
|
+
{member.a11y}
|
|
237
|
+
</Text>
|
|
238
|
+
)}
|
|
239
|
+
{parts.includes('props') && resolvedProps && <PropsTable props={resolvedProps} />}
|
|
240
|
+
{parts.includes('api') && (
|
|
241
|
+
<View style={styles.apiBlock}>
|
|
242
|
+
<Text style={styles.apiText}>{member.api}</Text>
|
|
243
|
+
</View>
|
|
244
|
+
)}
|
|
245
|
+
</View>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** V2b item 6: the disclosure toggle shows a chevron before its label (`›` closed, `⌄` open, as
|
|
250
|
+
* v8 does), on the `icon` role, hidden from screen readers (the toggle's own accessible name is
|
|
251
|
+
* its text label — Errata 4's `accessibilityState`/`aria-expanded` already say whether it's
|
|
252
|
+
* open). No animation. */
|
|
253
|
+
function DisclosureChevron({ open }: { open: boolean }) {
|
|
254
|
+
return (
|
|
255
|
+
<Text {...ARIA_HIDDEN} style={styles.chevron}>
|
|
256
|
+
{open ? '⌄' : '›'}
|
|
257
|
+
</Text>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Slice D1 item 9: "Notes and API" (using `apiLabel`) as a controlled toggle, closed by default —
|
|
262
|
+
* shared by the whole and every Part/Related member (never by a folded row, which has no second,
|
|
263
|
+
* nested toggle of its own: its own open state already decides whether its disclosure body shows). */
|
|
264
|
+
function MemberDisclosureToggle({
|
|
265
|
+
member,
|
|
266
|
+
apiLabel,
|
|
267
|
+
resolvesAddress,
|
|
268
|
+
}: {
|
|
269
|
+
member: CatalogMember & { props?: PropDef[] };
|
|
270
|
+
apiLabel: string;
|
|
271
|
+
resolvesAddress: (name: string) => boolean;
|
|
272
|
+
}) {
|
|
273
|
+
const [open, setOpen] = useState(false);
|
|
274
|
+
const [focused, setFocused] = useState(false);
|
|
275
|
+
const parts = disclosureContent(member, member.props);
|
|
276
|
+
if (parts.length === 0) return null;
|
|
277
|
+
const label = disclosureLabel(member, apiLabel, parts.includes('props'));
|
|
278
|
+
return (
|
|
279
|
+
<>
|
|
280
|
+
<Pressable
|
|
281
|
+
onPress={() => setOpen((value) => !value)}
|
|
282
|
+
onFocus={() => setFocused(true)}
|
|
283
|
+
onBlur={() => setFocused(false)}
|
|
284
|
+
accessibilityRole="button"
|
|
285
|
+
accessibilityState={{ expanded: open }}
|
|
286
|
+
{...({ 'aria-expanded': open } as Record<string, unknown>)}
|
|
287
|
+
style={[styles.disclosureToggle, focused && styles.focusRing]}
|
|
288
|
+
>
|
|
289
|
+
<DisclosureChevron open={open} />
|
|
290
|
+
<Text style={styles.disclosureToggleLabel}>{label}</Text>
|
|
291
|
+
</Pressable>
|
|
292
|
+
{open && <DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} />}
|
|
293
|
+
</>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
type FamilyMember = CatalogMember & { props?: PropDef[]; path: string };
|
|
298
|
+
|
|
299
|
+
interface MemberRenderProps {
|
|
300
|
+
member: FamilyMember;
|
|
301
|
+
/** Undefined for a Related member (spec §"Related": no pin, no gutter) — `numberFor` never
|
|
302
|
+
* assigns a number outside an `inWhole` section. */
|
|
303
|
+
number?: string;
|
|
304
|
+
allMemberIds: readonly string[];
|
|
305
|
+
defaultPreviewWidths?: PreviewWidths;
|
|
306
|
+
/** V2b item 9: the page's own label for the disclosure's API part (`FamilyDef.apiLabel`,
|
|
307
|
+
* default `'API'`). */
|
|
308
|
+
apiLabel: string;
|
|
309
|
+
/** V2d item 3: whether a name (a `composedOf` entry's `component`) resolves to a real catalog
|
|
310
|
+
* address — a page, member, or alias — so `UsesLine` knows which names to link. */
|
|
311
|
+
resolvesAddress: (name: string) => boolean;
|
|
312
|
+
// Pressable forwards its ref to its underlying `View` (react-native's own `Pressable.d.ts`), so
|
|
313
|
+
// a registered node is always a `View` instance whether it came from a block member's `View`
|
|
314
|
+
// heading target or a folded row's `Pressable`.
|
|
315
|
+
registerNode: (id: string, node: View | null) => void;
|
|
316
|
+
/** Slice D1 item 7 "Tree line": reports this Part's pin node, the same callback-ref pattern as
|
|
317
|
+
* `registerNode`, so a `Branch` can measure where to end its line without a `RefObject` having
|
|
318
|
+
* to be allocated and threaded down from an ancestor that doesn't yet know this part exists. */
|
|
319
|
+
pinRef?: (node: View | null) => void;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Slice D1 items 7–8: a Part (numbered, with a 52px gutter for its pin and tree line) or a
|
|
323
|
+
* Related member (no pin, no gutter) — otherwise the same header and body: name, description,
|
|
324
|
+
* specimens (through `Blocks`, when the member has any), the below-specimens line, and a
|
|
325
|
+
* closed-by-default disclosure. */
|
|
326
|
+
function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths, apiLabel, resolvesAddress, registerNode, pinRef }: MemberRenderProps & { level: number }) {
|
|
327
|
+
const surface = member.specimenSurface ?? DEFAULT_SPECIMEN_SURFACE;
|
|
328
|
+
const memberDef = useMemo(() => memberAsSectionDef(member), [member]);
|
|
329
|
+
const blocks = useMemo(
|
|
330
|
+
() => presentationBlocks(memberDef, { defaultPreviewWidths }).filter((block) => block.kind !== 'empty'),
|
|
331
|
+
[memberDef, defaultPreviewWidths],
|
|
332
|
+
);
|
|
333
|
+
const related = member.related ? linkifyRelated(member.related, allMemberIds) : undefined;
|
|
334
|
+
const numbered = number !== undefined;
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<View>
|
|
338
|
+
<View style={styles.partHeadRow}>
|
|
339
|
+
{numbered && (
|
|
340
|
+
<View style={styles.pinBox}>
|
|
341
|
+
{/* The tree line measures this wrapper, so it hugs the pin itself, not the gutter. */}
|
|
342
|
+
<View ref={pinRef ?? undefined}>
|
|
343
|
+
<Pin number={number} />
|
|
344
|
+
</View>
|
|
345
|
+
</View>
|
|
346
|
+
)}
|
|
347
|
+
<View style={styles.partHeadMain}>
|
|
348
|
+
<MemberNameRow
|
|
349
|
+
name={member.id}
|
|
350
|
+
fidelity={member.fidelity}
|
|
351
|
+
osComponent={member.osComponent}
|
|
352
|
+
heading
|
|
353
|
+
ariaLevel={ariaLevelFor(level)}
|
|
354
|
+
headingRef={(node) => registerNode(member.id, node)}
|
|
355
|
+
/>
|
|
356
|
+
</View>
|
|
357
|
+
</View>
|
|
358
|
+
{/* D2 item A1: `partBody`'s own rhythm gap applies to every member, numbered or not — only
|
|
359
|
+
the gutter indent (`partBodyIndented`) is conditional — so a Related member (never
|
|
360
|
+
numbered) still gets the same spacing between its own description/specimens/below-line/
|
|
361
|
+
disclosure as a numbered Part does. */}
|
|
362
|
+
<View style={[styles.partBody, numbered && styles.partBodyIndented]}>
|
|
363
|
+
<Text style={styles.memberDesc}>{member.description}</Text>
|
|
364
|
+
{blocks.length > 0 && (
|
|
365
|
+
// V2b item 5: a member's own specimen card sits CATALOG_SPACE.lg above the line below it
|
|
366
|
+
// — this wrapper supplies the extra margin beyond `partBody`'s own uniform CATALOG_SPACE.sm
|
|
367
|
+
// gap, instead of double-stacking with `Blocks`' own page-level marginBottom
|
|
368
|
+
// (`trimOuterMargin`), which together measured ~44px (v8: 16px).
|
|
369
|
+
<View style={styles.memberSpecimens}>
|
|
370
|
+
{/* V2b item 4: no uppercase "Variants"/"States" block label inside a member — v8 has none. */}
|
|
371
|
+
<Blocks blocks={blocks} def={memberDef} surface={surface} showLabels={false} trimOuterMargin />
|
|
372
|
+
</View>
|
|
373
|
+
)}
|
|
374
|
+
{member.notShown && <Text style={styles.belowLine}>{member.notShown}</Text>}
|
|
375
|
+
<BelowSpecimensLine
|
|
376
|
+
related={related}
|
|
377
|
+
showRelationship={false}
|
|
378
|
+
relationship={member.relationship}
|
|
379
|
+
source={member.source}
|
|
380
|
+
/>
|
|
381
|
+
<View style={styles.disclosureSpacer}>
|
|
382
|
+
<MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
|
|
383
|
+
</View>
|
|
384
|
+
</View>
|
|
385
|
+
</View>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** One row of a folded `list` section (spec §5.2 "Folded row"): the row's header — pin, name and
|
|
390
|
+
* description — is the toggle, and the open body is a sibling *outside* that button, so a related
|
|
391
|
+
* link or the PropsTable never sits inside a `role="button"` and never bubbles its own click back
|
|
392
|
+
* into the toggle. Closed, it shows only the header and description; open, it adds the below-the-
|
|
393
|
+
* specimens line and the disclosure body directly (no nested second toggle). Folded rows never
|
|
394
|
+
* show specimens (spec's v8 reference keeps every folded-row example documentation-only). Slice
|
|
395
|
+
* D1: no gutter either way — a folded row's own pin (when it has a number) sits inline with its
|
|
396
|
+
* name, never offsetting the row's body. */
|
|
397
|
+
function FoldedRow({ member, number, allMemberIds, resolvesAddress, open, onToggle, registerNode }: MemberRenderProps & { open: boolean; onToggle: () => void }) {
|
|
398
|
+
const [focused, setFocused] = useState(false);
|
|
399
|
+
const related = member.related ? linkifyRelated(member.related, allMemberIds) : undefined;
|
|
400
|
+
const parts = disclosureContent(member, member.props);
|
|
401
|
+
return (
|
|
402
|
+
<View style={styles.foldedRow}>
|
|
403
|
+
<Pressable
|
|
404
|
+
ref={(node) => registerNode(member.id, node)}
|
|
405
|
+
onPress={onToggle}
|
|
406
|
+
onFocus={() => setFocused(true)}
|
|
407
|
+
onBlur={() => setFocused(false)}
|
|
408
|
+
accessibilityRole="button"
|
|
409
|
+
accessibilityState={{ expanded: open }}
|
|
410
|
+
{...({ 'aria-expanded': open } as Record<string, unknown>)}
|
|
411
|
+
style={[styles.foldedRowToggle, focused && styles.focusRing]}
|
|
412
|
+
>
|
|
413
|
+
<View style={styles.foldedRowHead}>
|
|
414
|
+
{number !== undefined && <Pin number={number} />}
|
|
415
|
+
<MemberNameRow name={member.id} fidelity={member.fidelity} osComponent={member.osComponent} heading={false} />
|
|
416
|
+
</View>
|
|
417
|
+
<Text style={styles.memberDesc}>{member.description}</Text>
|
|
418
|
+
</Pressable>
|
|
419
|
+
{/* Sibling of the toggle `Pressable`, same pattern `MemberDisclosureToggle` uses above — a
|
|
420
|
+
related link (or the PropsTable) here must never sit inside a `role="button"`, and on
|
|
421
|
+
web must never bubble its own click into this row's own press handler and close it. */}
|
|
422
|
+
{open && (
|
|
423
|
+
<View style={styles.foldedRowBody}>
|
|
424
|
+
<BelowSpecimensLine related={related} showRelationship={false} relationship={member.relationship} source={member.source} />
|
|
425
|
+
{parts.length > 0 && (
|
|
426
|
+
<DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} />
|
|
427
|
+
)}
|
|
428
|
+
</View>
|
|
429
|
+
)}
|
|
430
|
+
</View>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Slice D1 item 7 "Tree line": the elbow from a parent's pin down to each direct child's pin —
|
|
435
|
+
* measured (`View.measureLayout`, so it keeps working after wrapping/resizing) rather than
|
|
436
|
+
* computed from styling constants, the same technique `CatalogShell`'s own DOM-only escape
|
|
437
|
+
* hatches use for layout that CSS alone can't express. A border-left + border-bottom elbow with a
|
|
438
|
+
* 24px radius turn, in the approved reference's own subtle-accent line color. Decorative: hidden
|
|
439
|
+
* from accessibility. */
|
|
440
|
+
function Branch({
|
|
441
|
+
parentId,
|
|
442
|
+
childIds,
|
|
443
|
+
pinNodesRef,
|
|
444
|
+
children,
|
|
445
|
+
}: {
|
|
446
|
+
parentId: string;
|
|
447
|
+
childIds: readonly string[];
|
|
448
|
+
pinNodesRef: React.RefObject<Map<string, View | null>>;
|
|
449
|
+
children: React.ReactNode;
|
|
450
|
+
}) {
|
|
451
|
+
const containerRef = useRef<View>(null);
|
|
452
|
+
const [lines, setLines] = useState<Array<{ left: number; top: number; width: number; height: number }>>([]);
|
|
453
|
+
|
|
454
|
+
const measure = () => {
|
|
455
|
+
const container = containerRef.current;
|
|
456
|
+
// Web: measure from the rendered boxes, so the vertical line sits exactly under the centre of
|
|
457
|
+
// the parent's pin (`measureLayout` reports offsets from the nearest positioned ancestor there,
|
|
458
|
+
// which put the line to the right of the pin). The line is 2px wide, so it starts 1px left.
|
|
459
|
+
if (Platform.OS === 'web') {
|
|
460
|
+
type Box = { getBoundingClientRect?: () => { left: number; top: number; width: number; height: number } };
|
|
461
|
+
const box = (node: unknown) => (node as Box | null | undefined)?.getBoundingClientRect?.();
|
|
462
|
+
const c = box(container), p = box(pinNodesRef.current.get(parentId));
|
|
463
|
+
if (!c || !p || childIds.length === 0) return;
|
|
464
|
+
const x = p.left + p.width / 2 - c.left - 1;
|
|
465
|
+
const top = p.top + p.height - c.top + 4;
|
|
466
|
+
const next: Array<{ left: number; top: number; width: number; height: number }> = [];
|
|
467
|
+
for (const childId of childIds) {
|
|
468
|
+
const k = box(pinNodesRef.current.get(childId));
|
|
469
|
+
if (!k) return;
|
|
470
|
+
const y = k.top + k.height / 2 - c.top + 1;
|
|
471
|
+
next.push({ left: x, top, width: Math.max(0, k.left - c.left - x - 4), height: Math.max(0, y - top) });
|
|
472
|
+
}
|
|
473
|
+
setLines(next);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const parentPin = pinNodesRef.current.get(parentId) as unknown as MeasurableNode | undefined;
|
|
477
|
+
if (!container || !parentPin?.measureLayout || childIds.length === 0) return;
|
|
478
|
+
parentPin.measureLayout(
|
|
479
|
+
container as unknown as number,
|
|
480
|
+
(px: number, py: number, pw: number, ph: number) => {
|
|
481
|
+
const x = px + pw / 2;
|
|
482
|
+
const top = py + ph + 4;
|
|
483
|
+
const next: Array<{ left: number; top: number; width: number; height: number }> = [];
|
|
484
|
+
for (const childId of childIds) {
|
|
485
|
+
const childPin = pinNodesRef.current.get(childId) as unknown as MeasurableNode | undefined;
|
|
486
|
+
childPin?.measureLayout?.(
|
|
487
|
+
container as unknown as number,
|
|
488
|
+
(cx: number, cy: number, _cw: number, ch: number) => {
|
|
489
|
+
const y = cy + ch / 2;
|
|
490
|
+
next.push({ left: x, top, width: Math.max(0, cx - x), height: Math.max(0, y - top) });
|
|
491
|
+
if (next.length === childIds.length) setLines(next);
|
|
492
|
+
},
|
|
493
|
+
() => {},
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
() => {},
|
|
498
|
+
);
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
502
|
+
useEffect(measure, []);
|
|
503
|
+
|
|
504
|
+
return (
|
|
505
|
+
<View ref={containerRef} style={styles.branch} onLayout={measure}>
|
|
506
|
+
{children}
|
|
507
|
+
{lines.map((line, i) => (
|
|
508
|
+
<View key={i} {...ARIA_HIDDEN} style={[styles.treeLine, line]} />
|
|
509
|
+
))}
|
|
510
|
+
</View>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** A node with `measureLayout` (every mounted `View`'s native handle) — narrower than RN's own
|
|
515
|
+
* `View` type, which doesn't expose this method in its public TypeScript surface even though
|
|
516
|
+
* every platform implements it. */
|
|
517
|
+
interface MeasurableNode {
|
|
518
|
+
measureLayout?: (relativeToNativeNode: number, onSuccess: (left: number, top: number, width: number, height: number) => void, onFail: () => void) => void;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** One Part in its tree position: rendered through `FamilyPart` as always, wrapped in a `Branch`
|
|
522
|
+
* only when it has children (a leaf costs nothing extra), with its children nested inside its own
|
|
523
|
+
* block (spec: "so the lines don't cross other parts") at one more 52px gutter. */
|
|
524
|
+
function PartTreeBranch({
|
|
525
|
+
node,
|
|
526
|
+
byId,
|
|
527
|
+
outline,
|
|
528
|
+
allMemberIds,
|
|
529
|
+
defaultPreviewWidths,
|
|
530
|
+
apiLabel,
|
|
531
|
+
resolvesAddress,
|
|
532
|
+
registerNode,
|
|
533
|
+
pinNodesRef,
|
|
534
|
+
}: {
|
|
535
|
+
node: PartTreeEntry;
|
|
536
|
+
byId: ReadonlyMap<string, FamilyMember>;
|
|
537
|
+
outline: FamilyDef['outline'];
|
|
538
|
+
allMemberIds: readonly string[];
|
|
539
|
+
defaultPreviewWidths?: PreviewWidths;
|
|
540
|
+
apiLabel: string;
|
|
541
|
+
resolvesAddress: (name: string) => boolean;
|
|
542
|
+
registerNode: (id: string, node: View | null) => void;
|
|
543
|
+
pinNodesRef: React.RefObject<Map<string, View | null>>;
|
|
544
|
+
}) {
|
|
545
|
+
const member = byId.get(node.id);
|
|
546
|
+
if (!member) return null;
|
|
547
|
+
const part = (
|
|
548
|
+
<FamilyPart
|
|
549
|
+
member={member}
|
|
550
|
+
number={outline.get(node.id)?.number}
|
|
551
|
+
level={outline.get(node.id)?.level ?? 2}
|
|
552
|
+
allMemberIds={allMemberIds}
|
|
553
|
+
defaultPreviewWidths={defaultPreviewWidths}
|
|
554
|
+
apiLabel={apiLabel}
|
|
555
|
+
resolvesAddress={resolvesAddress}
|
|
556
|
+
registerNode={registerNode}
|
|
557
|
+
pinRef={(pinNode) => {
|
|
558
|
+
if (pinNode) pinNodesRef.current.set(node.id, pinNode);
|
|
559
|
+
else pinNodesRef.current.delete(node.id);
|
|
560
|
+
}}
|
|
561
|
+
/>
|
|
562
|
+
);
|
|
563
|
+
if (node.children.length === 0) return part;
|
|
564
|
+
return (
|
|
565
|
+
<Branch parentId={node.id} childIds={node.children.map((child) => child.id)} pinNodesRef={pinNodesRef}>
|
|
566
|
+
{part}
|
|
567
|
+
<View style={styles.subparts}>
|
|
568
|
+
{node.children.map((child) => (
|
|
569
|
+
<PartTreeBranch
|
|
570
|
+
key={child.id}
|
|
571
|
+
node={child}
|
|
572
|
+
byId={byId}
|
|
573
|
+
outline={outline}
|
|
574
|
+
allMemberIds={allMemberIds}
|
|
575
|
+
defaultPreviewWidths={defaultPreviewWidths}
|
|
576
|
+
apiLabel={apiLabel}
|
|
577
|
+
resolvesAddress={resolvesAddress}
|
|
578
|
+
registerNode={registerNode}
|
|
579
|
+
pinNodesRef={pinNodesRef}
|
|
580
|
+
/>
|
|
581
|
+
))}
|
|
582
|
+
</View>
|
|
583
|
+
</Branch>
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Slice D1 item 4: one `inWhole` or Related section's members, rendered without its own authored
|
|
588
|
+
* label (only the umbrella "Parts"/"Related" label shows — `FamilyPage` renders that once per
|
|
589
|
+
* bucket). `kind` alone still decides full blocks versus one folded list card. */
|
|
590
|
+
function FamilySectionGroup({
|
|
591
|
+
section,
|
|
592
|
+
members,
|
|
593
|
+
outline,
|
|
594
|
+
allMemberIds,
|
|
595
|
+
defaultPreviewWidths,
|
|
596
|
+
apiLabel,
|
|
597
|
+
resolvesAddress,
|
|
598
|
+
openRows,
|
|
599
|
+
onToggleRow,
|
|
600
|
+
registerNode,
|
|
601
|
+
}: {
|
|
602
|
+
section: CatalogMemberSection;
|
|
603
|
+
members: FamilyMember[];
|
|
604
|
+
outline: FamilyDef['outline'];
|
|
605
|
+
allMemberIds: readonly string[];
|
|
606
|
+
defaultPreviewWidths?: PreviewWidths;
|
|
607
|
+
apiLabel: string;
|
|
608
|
+
resolvesAddress: (name: string) => boolean;
|
|
609
|
+
openRows: ReadonlySet<string>;
|
|
610
|
+
onToggleRow: (id: string) => void;
|
|
611
|
+
registerNode: (id: string, node: View | null) => void;
|
|
612
|
+
}) {
|
|
613
|
+
const byId = useMemo(() => new Map(members.map((member) => [member.id, member])), [members]);
|
|
614
|
+
const pinNodesRef = useRef<Map<string, View | null>>(new Map());
|
|
615
|
+
if (section.kind === 'blocks') {
|
|
616
|
+
const tree = buildPartTree(members.map((member) => member.id), outline);
|
|
617
|
+
return (
|
|
618
|
+
<View style={styles.blocksSection}>
|
|
619
|
+
{tree.map((node) => (
|
|
620
|
+
<PartTreeBranch
|
|
621
|
+
key={node.id}
|
|
622
|
+
node={node}
|
|
623
|
+
byId={byId}
|
|
624
|
+
outline={outline}
|
|
625
|
+
allMemberIds={allMemberIds}
|
|
626
|
+
defaultPreviewWidths={defaultPreviewWidths}
|
|
627
|
+
apiLabel={apiLabel}
|
|
628
|
+
resolvesAddress={resolvesAddress}
|
|
629
|
+
registerNode={registerNode}
|
|
630
|
+
pinNodesRef={pinNodesRef}
|
|
631
|
+
/>
|
|
632
|
+
))}
|
|
633
|
+
</View>
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
return (
|
|
637
|
+
<View style={styles.foldedList}>
|
|
638
|
+
{members.map((member, i) => (
|
|
639
|
+
<View key={member.id} style={[styles.foldedListRow, i < members.length - 1 && styles.foldedListRowDivider]}>
|
|
640
|
+
<FoldedRow
|
|
641
|
+
member={member}
|
|
642
|
+
number={outline.get(member.id)?.number}
|
|
643
|
+
allMemberIds={allMemberIds}
|
|
644
|
+
defaultPreviewWidths={defaultPreviewWidths}
|
|
645
|
+
apiLabel={apiLabel}
|
|
646
|
+
resolvesAddress={resolvesAddress}
|
|
647
|
+
open={openRows.has(member.id)}
|
|
648
|
+
onToggle={() => onToggleRow(member.id)}
|
|
649
|
+
registerNode={registerNode}
|
|
650
|
+
/>
|
|
651
|
+
</View>
|
|
652
|
+
))}
|
|
653
|
+
</View>
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Slice D1 item 4: the two umbrella labels — "Parts" groups every `inWhole` section, "Related"
|
|
658
|
+
* every other one. A divider sits above each, and nothing else renders between the authored
|
|
659
|
+
* sections it covers (item 10: authored `section.label`s stay in the data, never on screen). */
|
|
660
|
+
function FamilyLabeledGroup({
|
|
661
|
+
label,
|
|
662
|
+
sections,
|
|
663
|
+
outline,
|
|
664
|
+
byId,
|
|
665
|
+
allMemberIds,
|
|
666
|
+
defaultPreviewWidths,
|
|
667
|
+
apiLabel,
|
|
668
|
+
resolvesAddress,
|
|
669
|
+
openRows,
|
|
670
|
+
onToggleRow,
|
|
671
|
+
registerNode,
|
|
672
|
+
}: {
|
|
673
|
+
label: 'Parts' | 'Related';
|
|
674
|
+
sections: CatalogMemberSection[];
|
|
675
|
+
outline: FamilyDef['outline'];
|
|
676
|
+
byId: ReadonlyMap<string, FamilyMember>;
|
|
677
|
+
allMemberIds: readonly string[];
|
|
678
|
+
defaultPreviewWidths?: PreviewWidths;
|
|
679
|
+
apiLabel: string;
|
|
680
|
+
resolvesAddress: (name: string) => boolean;
|
|
681
|
+
openRows: ReadonlySet<string>;
|
|
682
|
+
onToggleRow: (id: string) => void;
|
|
683
|
+
registerNode: (id: string, node: View | null) => void;
|
|
684
|
+
}) {
|
|
685
|
+
if (sections.length === 0) return null;
|
|
686
|
+
return (
|
|
687
|
+
<View style={styles.labeledGroup}>
|
|
688
|
+
{/* Spec §5.2 "Heading semantics": "Parts"/"Related" are aria-level 2 headings — one level
|
|
689
|
+
under the page title (1). Slice D1 removed the whole's own heading, so this level is no
|
|
690
|
+
longer shared with it. */}
|
|
691
|
+
<Text role="heading" {...({ 'aria-level': 2 } as Record<string, unknown>)} style={styles.sectionLabel}>
|
|
692
|
+
{label}
|
|
693
|
+
</Text>
|
|
694
|
+
<View style={styles.labeledGroupBody}>
|
|
695
|
+
{sections.map((section, index) => (
|
|
696
|
+
// V2c item 14: keyed by index, not the section's own label — two sections sharing a
|
|
697
|
+
// label (an already-reported, degenerate shape) must never collide as React keys.
|
|
698
|
+
<FamilySectionGroup
|
|
699
|
+
key={index}
|
|
700
|
+
section={section}
|
|
701
|
+
// V2c item 2: a section listing an id that isn't a real member (already reported by
|
|
702
|
+
// `validateFamily`) must degrade to a missing block, never a crash reading `byId.get(id)!`.
|
|
703
|
+
members={section.members.flatMap((id) => { const member = byId.get(id); return member ? [member] : []; })}
|
|
704
|
+
outline={outline}
|
|
705
|
+
allMemberIds={allMemberIds}
|
|
706
|
+
defaultPreviewWidths={defaultPreviewWidths}
|
|
707
|
+
apiLabel={apiLabel}
|
|
708
|
+
resolvesAddress={resolvesAddress}
|
|
709
|
+
openRows={openRows}
|
|
710
|
+
onToggleRow={onToggleRow}
|
|
711
|
+
registerNode={registerNode}
|
|
712
|
+
/>
|
|
713
|
+
))}
|
|
714
|
+
</View>
|
|
715
|
+
</View>
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Slice D1 item 5: the family page's own member records and computed outline, read by `AnatomyPin`
|
|
720
|
+
* so a page's `anatomy.picture` (author-supplied markup, possibly arbitrarily positioned) never
|
|
721
|
+
* hardcodes a number. `undefined` outside a family page's own anatomy box. */
|
|
722
|
+
const FamilyAnatomyContext = createContext<{ byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline'] } | undefined>(undefined);
|
|
723
|
+
|
|
724
|
+
/** Slice D1 item 5: a pin for use inside `anatomy.picture`. `member` names one of this page's
|
|
725
|
+
* members and shows its number (dashed when it has no area of its own), looked up through
|
|
726
|
+
* `FamilyAnatomyContext` — renders nothing for an unknown member, one with no number (D2 item A2:
|
|
727
|
+
* already a `validateFamily` issue), or outside a family page's own anatomy box (no context).
|
|
728
|
+
* `other` marks a component documented on another page with the unnumbered ↗ pin the legend
|
|
729
|
+
* uses, and always renders it — it needs no context, with or without a family page in scope. */
|
|
730
|
+
export function AnatomyPin({ member, other }: { member?: string; other?: string }) {
|
|
731
|
+
const context = useContext(FamilyAnatomyContext);
|
|
732
|
+
if (other !== undefined) {
|
|
733
|
+
return (
|
|
734
|
+
<View style={styles.extPin} {...ARIA_HIDDEN}>
|
|
735
|
+
<Text style={styles.extPinText}>↗</Text>
|
|
736
|
+
</View>
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
const pin = context && member !== undefined ? anatomyPinFor(member, context.byId, context.outline) : undefined;
|
|
740
|
+
if (!pin) return null;
|
|
741
|
+
return <Pin number={pin.number} dashed={pin.dashed} />;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Slice D1 item 5: one legend row naming a member of this page — a pin (looked up the same way
|
|
745
|
+
* `AnatomyPin` does), its name linked to the member's address, and the row's own description. */
|
|
746
|
+
function AnatomyMemberRow({ memberId, description, byId, outline }: { memberId: string; description: string; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline'] }) {
|
|
747
|
+
const pin = anatomyPinFor(memberId, byId, outline);
|
|
748
|
+
return (
|
|
749
|
+
<View style={styles.legendRow}>
|
|
750
|
+
<View style={styles.legendPinSlot}>{pin && <Pin number={pin.number} dashed={pin.dashed} />}</View>
|
|
751
|
+
<View style={styles.legendBody}>
|
|
752
|
+
<Text role="link" style={styles.legendName} {...({ href: hashForId(memberId) } as Record<string, unknown>)}>
|
|
753
|
+
{memberId}
|
|
754
|
+
</Text>
|
|
755
|
+
<Text style={styles.legendDesc}>{description}</Text>
|
|
756
|
+
</View>
|
|
757
|
+
</View>
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Slice D1 item 5: one legend row naming a component documented elsewhere — never numbered; its
|
|
762
|
+
* own name links only when it resolves to a real catalog address. */
|
|
763
|
+
function AnatomyOtherRow({ name, description, resolvesAddress }: { name: string; description: string; resolvesAddress: (name: string) => boolean }) {
|
|
764
|
+
const resolved = resolvesAddress(name);
|
|
765
|
+
return (
|
|
766
|
+
<View style={styles.legendRow}>
|
|
767
|
+
<View style={styles.legendPinSlot} {...ARIA_HIDDEN}>
|
|
768
|
+
<View style={styles.extPin}>
|
|
769
|
+
<Text style={styles.extPinText}>↗</Text>
|
|
770
|
+
</View>
|
|
771
|
+
</View>
|
|
772
|
+
<View style={styles.legendBody}>
|
|
773
|
+
<View style={styles.legendNameRow}>
|
|
774
|
+
{resolved ? (
|
|
775
|
+
<Text role="link" style={styles.legendName} {...({ href: hashForId(name) } as Record<string, unknown>)}>
|
|
776
|
+
{name}
|
|
777
|
+
</Text>
|
|
778
|
+
) : (
|
|
779
|
+
<Text style={styles.legendName}>{name}</Text>
|
|
780
|
+
)}
|
|
781
|
+
<Text style={styles.legendExt}>Other page</Text>
|
|
782
|
+
</View>
|
|
783
|
+
<Text style={styles.legendDesc}>{description}</Text>
|
|
784
|
+
</View>
|
|
785
|
+
</View>
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function AnatomyGroup({ group, byId, outline, resolvesAddress }: { group: CatalogAnatomyGroup; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean }) {
|
|
790
|
+
return (
|
|
791
|
+
<View>
|
|
792
|
+
<Text style={styles.legendGroupLabel}>{group.label}</Text>
|
|
793
|
+
{group.items.map((item, i) =>
|
|
794
|
+
'member' in item ? (
|
|
795
|
+
<AnatomyMemberRow key={i} memberId={item.member} description={item.description} byId={byId} outline={outline} />
|
|
796
|
+
) : (
|
|
797
|
+
<AnatomyOtherRow key={i} name={item.other} description={item.description} resolvesAddress={resolvesAddress} />
|
|
798
|
+
),
|
|
799
|
+
)}
|
|
800
|
+
</View>
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Slice D1 item 5: the anatomy box — always first (right after the page head), a plain-card
|
|
805
|
+
* "Anatomy" label above the picture (the same label-outside-card treatment the specimen grids
|
|
806
|
+
* below it use), and a legend beside it (six items or fewer) or below it in up to three balanced
|
|
807
|
+
* columns (more than six). */
|
|
808
|
+
function AnatomyBox({ anatomy, byId, outline, resolvesAddress }: { anatomy: CatalogAnatomy; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean }) {
|
|
809
|
+
const totalItems = anatomy.groups.reduce((sum, group) => sum + group.items.length, 0);
|
|
810
|
+
const stacked = totalItems > 6;
|
|
811
|
+
const contextValue = useMemo(() => ({ byId, outline }), [byId, outline]);
|
|
812
|
+
return (
|
|
813
|
+
<FamilyAnatomyContext.Provider value={contextValue}>
|
|
814
|
+
<View style={styles.anatomy}>
|
|
815
|
+
<Text style={styles.anatomyHeadText}>Anatomy</Text>
|
|
816
|
+
<View style={styles.anatomyCard}>
|
|
817
|
+
{stacked ? (
|
|
818
|
+
<View>
|
|
819
|
+
<View style={styles.anatomyStage}>{anatomy.picture}</View>
|
|
820
|
+
<View style={styles.anatomyColumns}>
|
|
821
|
+
{anatomyColumns(anatomy.groups, 3).map((column, i) => (
|
|
822
|
+
<View key={i} style={styles.anatomyColumn}>
|
|
823
|
+
{column.map((group, j) => (
|
|
824
|
+
<AnatomyGroup key={j} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} />
|
|
825
|
+
))}
|
|
826
|
+
</View>
|
|
827
|
+
))}
|
|
828
|
+
</View>
|
|
829
|
+
</View>
|
|
830
|
+
) : (
|
|
831
|
+
<View style={styles.anatomyBody}>
|
|
832
|
+
<View style={styles.anatomyStage}>{anatomy.picture}</View>
|
|
833
|
+
<View style={styles.anatomyLegend}>
|
|
834
|
+
{anatomy.groups.map((group, i) => (
|
|
835
|
+
<AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} />
|
|
836
|
+
))}
|
|
837
|
+
</View>
|
|
838
|
+
</View>
|
|
839
|
+
)}
|
|
840
|
+
</View>
|
|
841
|
+
</View>
|
|
842
|
+
</FamilyAnatomyContext.Provider>
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Scrolls a member's node into view and moves focus to it (spec §5.3 "Member address"). The jump
|
|
847
|
+
* is always instant, like a normal anchor link: a smooth `scrollIntoView` does not reliably move
|
|
848
|
+
* the shell's nested scroll container on web (the same limitation `CatalogShell`'s
|
|
849
|
+
* `resetMainScroll` works around), and an instant jump also satisfies Reduce Motion. Native has
|
|
850
|
+
* no scroll-into-view of its own; `AccessibilityInfo`'s focus call is enough there. */
|
|
851
|
+
function scrollAndFocusMember(node: View): void {
|
|
852
|
+
if (Platform.OS === 'web') {
|
|
853
|
+
const dom = node as unknown as { scrollIntoView?: (options: { behavior: 'auto'; block: 'start' }) => void; focus?: (options?: { preventScroll?: boolean }) => void };
|
|
854
|
+
dom.scrollIntoView?.({ behavior: 'auto', block: 'start' });
|
|
855
|
+
dom.focus?.({ preventScroll: true });
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const handle = findNodeHandle(node as unknown as View);
|
|
859
|
+
if (handle != null) AccessibilityInfo.setAccessibilityFocus(handle);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* A family page (spec §5, Slice D1): the page head, the anatomy box (if `anatomy` is set), the
|
|
864
|
+
* whole's own specimens with no header of its own, then Parts and Related — the only two section
|
|
865
|
+
* labels (item 10; authored `section.label`s stay in the data, never on screen). Renders exactly
|
|
866
|
+
* like a normal page's head (breadcrumb, title, summary, pager) when `def.family` is set, in place
|
|
867
|
+
* of `SectionBlock`'s usual single-component layout (`CatalogShell`).
|
|
868
|
+
*/
|
|
869
|
+
export function FamilyPage<TId extends string>({
|
|
870
|
+
def,
|
|
871
|
+
groupLabel,
|
|
872
|
+
breadcrumbRoot,
|
|
873
|
+
banner,
|
|
874
|
+
pager,
|
|
875
|
+
headingRef,
|
|
876
|
+
defaultPreviewWidths,
|
|
877
|
+
focusMemberId,
|
|
878
|
+
focusNonce,
|
|
879
|
+
knownAddressIds,
|
|
880
|
+
}: {
|
|
881
|
+
def: SectionDef<TId>;
|
|
882
|
+
groupLabel?: string;
|
|
883
|
+
breadcrumbRoot?: string;
|
|
884
|
+
banner?: React.ReactNode;
|
|
885
|
+
pager?: SectionPager<TId>;
|
|
886
|
+
headingRef?: React.Ref<View>;
|
|
887
|
+
defaultPreviewWidths?: PreviewWidths;
|
|
888
|
+
/** Spec §5.3 "Member address": the member to scroll to, open (if it's a folded row) and focus
|
|
889
|
+
* once this page has rendered — set by `CatalogShell` from `resolveRoute`'s `member` route.
|
|
890
|
+
* `undefined` for a plain family-address visit, which focuses the page head instead, like any
|
|
891
|
+
* other page. */
|
|
892
|
+
focusMemberId?: string;
|
|
893
|
+
/** V2c item 11: bumped by `CatalogShell` on every activation of `focusMemberId`, even a repeat
|
|
894
|
+
* one — a plain string/value comparison would otherwise never re-trigger the jump for
|
|
895
|
+
* re-selecting the same member address twice in a row. */
|
|
896
|
+
focusNonce?: number;
|
|
897
|
+
/** V2d item 3: every real catalog address outside this family — every other page's own id, plus
|
|
898
|
+
* every member id/alias `catalogNavigation.ts`'s `aliasTargets` already resolves — so a
|
|
899
|
+
* member's `composedOf` can link to a name belonging to a different family, or a plain
|
|
900
|
+
* component/page, not only to a sibling member of this same family (`allMemberIds`, always
|
|
901
|
+
* included below regardless of this prop). `CatalogShell` passes its own catalog-wide set;
|
|
902
|
+
* omitted when `FamilyPage` is used standalone (e.g. this very page's own live demo), where
|
|
903
|
+
* only this family's own members can resolve. */
|
|
904
|
+
knownAddressIds?: ReadonlySet<string>;
|
|
905
|
+
}) {
|
|
906
|
+
const family = def.family!;
|
|
907
|
+
const apiLabel = family.apiLabel ?? 'API';
|
|
908
|
+
const byId = useMemo(() => new Map(family.members.map((member) => [member.id, member])), [family.members]);
|
|
909
|
+
// N7: a `whole` that isn't one of its own members (bad data, already reported by
|
|
910
|
+
// `validateFamily`) must degrade to a missing block, never a crash reading an absent member.
|
|
911
|
+
const wholeMember = resolveWhole(byId, family.whole);
|
|
912
|
+
const partSections = useMemo(() => family.sections.filter((section) => section.inWhole), [family.sections]);
|
|
913
|
+
const relatedSections = useMemo(() => family.sections.filter((section) => !section.inWhole), [family.sections]);
|
|
914
|
+
const allMemberIds = useMemo(() => family.members.map((member) => member.id), [family.members]);
|
|
915
|
+
const resolvesAddress = useMemo(() => {
|
|
916
|
+
const ids = new Set<string>([...(knownAddressIds ?? []), ...allMemberIds]);
|
|
917
|
+
return (name: string) => ids.has(name);
|
|
918
|
+
}, [knownAddressIds, allMemberIds]);
|
|
919
|
+
|
|
920
|
+
const [openRows, setOpenRows] = useState<ReadonlySet<string>>(() => new Set());
|
|
921
|
+
const toggleRow = (id: string) => setOpenRows((prev) => (prev.has(id) ? new Set([...prev].filter((x) => x !== id)) : new Set(prev).add(id)));
|
|
922
|
+
|
|
923
|
+
const nodesRef = useRef<Map<string, View | null>>(new Map());
|
|
924
|
+
const registerNode = (id: string, node: View | null) => {
|
|
925
|
+
if (node) nodesRef.current.set(id, node);
|
|
926
|
+
else nodesRef.current.delete(id);
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
// V2c item 1 (one-shot jump) + item 11 (repeat activation): `jumpRef` tracks how many scroll
|
|
930
|
+
// passes are still owed to the CURRENT activation of `focusMemberId` (keyed by `focusNonce`, so
|
|
931
|
+
// re-selecting the same member again still counts as a fresh activation). Opening a folded row
|
|
932
|
+
// (so it has something to scroll to) is kept separate from the scroll/focus effect below (V2b
|
|
933
|
+
// item 8) so a row that needs opening first gets a *second* run of that effect, after React has
|
|
934
|
+
// committed its now-open (and taller) layout, rather than scrolling against the still-closed one.
|
|
935
|
+
const jumpRef = useRef<MemberJumpState | undefined>(undefined);
|
|
936
|
+
useEffect(() => {
|
|
937
|
+
if (!focusMemberId) return;
|
|
938
|
+
const owningSection = sectionFor(family.sections, focusMemberId);
|
|
939
|
+
// Two passes only when this activation actually opens a currently-closed row (so its layout
|
|
940
|
+
// really does change between them) — a repeat activation (V2c item 11) of an already-open row,
|
|
941
|
+
// or any block member, only ever needs the one pass, so a later, unrelated toggle never finds
|
|
942
|
+
// a stray second pass still owed.
|
|
943
|
+
const needsOpen = owningSection?.kind === 'list' && !openRows.has(focusMemberId);
|
|
944
|
+
jumpRef.current = { memberId: focusMemberId, passesLeft: needsOpen ? 2 : 1 };
|
|
945
|
+
if (owningSection?.kind === 'list') setOpenRows((prev) => new Set(prev).add(focusMemberId));
|
|
946
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
947
|
+
}, [focusMemberId, focusNonce]);
|
|
948
|
+
|
|
949
|
+
useEffect(() => {
|
|
950
|
+
if (!focusMemberId || !shouldRunMemberJump(jumpRef.current, focusMemberId)) return;
|
|
951
|
+
const node = nodesRef.current.get(focusMemberId);
|
|
952
|
+
if (node) scrollAndFocusMember(node);
|
|
953
|
+
// Re-running on `openRows` is what makes the re-run above matter: the first pass (the row
|
|
954
|
+
// still closed) and the second (now open) both scroll, and only the second's measurement —
|
|
955
|
+
// taken from the committed, open layout — is the one that actually lands near the top.
|
|
956
|
+
// `focusNonce` (V2c item 11) is also a dependency so a repeat activation of a block member (or
|
|
957
|
+
// an already-open row — see the previous effect's `needsOpen`) still re-runs this even when
|
|
958
|
+
// `openRows` itself never changes. Once every owed pass has run, `shouldRunMemberJump` stops
|
|
959
|
+
// this from firing again — toggling any row afterwards must never scroll or move focus again.
|
|
960
|
+
jumpRef.current = { memberId: focusMemberId, passesLeft: jumpRef.current!.passesLeft - 1 };
|
|
961
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
962
|
+
}, [focusMemberId, openRows, focusNonce]);
|
|
963
|
+
|
|
964
|
+
return (
|
|
965
|
+
<View>
|
|
966
|
+
{groupLabel && <Text style={styles.breadcrumb}>{breadcrumbRoot ? `${breadcrumbRoot} / ${groupLabel}` : groupLabel}</Text>}
|
|
967
|
+
{banner}
|
|
968
|
+
<View style={styles.pageHead}>
|
|
969
|
+
<View style={styles.titlebar}>
|
|
970
|
+
<View style={styles.titleText}>
|
|
971
|
+
<View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
|
|
972
|
+
<Text style={styles.title}>{def.title ?? def.id}</Text>
|
|
973
|
+
</View>
|
|
974
|
+
<Text style={styles.desc}>{def.description}</Text>
|
|
975
|
+
{family.notShown && (
|
|
976
|
+
<Text style={styles.notShown}>
|
|
977
|
+
<Text style={styles.notShownLead}>Not shown in the browser: </Text>
|
|
978
|
+
{family.notShown}
|
|
979
|
+
</Text>
|
|
980
|
+
)}
|
|
981
|
+
</View>
|
|
982
|
+
{pager && <SectionPagerRow pager={pager} />}
|
|
983
|
+
</View>
|
|
984
|
+
</View>
|
|
985
|
+
|
|
986
|
+
{family.anatomy && <AnatomyBox anatomy={family.anatomy} byId={byId} outline={family.outline} resolvesAddress={resolvesAddress} />}
|
|
987
|
+
|
|
988
|
+
{wholeMember && (
|
|
989
|
+
<View style={styles.whole} ref={(node) => registerNode(wholeMember.id, node)} tabIndex={-1}>
|
|
990
|
+
{(() => {
|
|
991
|
+
const surface = wholeMember.specimenSurface ?? DEFAULT_SPECIMEN_SURFACE;
|
|
992
|
+
const wholeDef = memberAsSectionDef(wholeMember);
|
|
993
|
+
const blocks = presentationBlocks(wholeDef, { defaultPreviewWidths }).filter((block) => block.kind !== 'empty');
|
|
994
|
+
return (
|
|
995
|
+
<>
|
|
996
|
+
{blocks.length > 0 && (
|
|
997
|
+
<View style={styles.memberSpecimens}>
|
|
998
|
+
<Blocks blocks={blocks} def={wholeDef} surface={surface} showLabels={false} trimOuterMargin />
|
|
999
|
+
</View>
|
|
1000
|
+
)}
|
|
1001
|
+
{wholeMember.notShown && <Text style={styles.belowLine}>{wholeMember.notShown}</Text>}
|
|
1002
|
+
<WholeBelowLine name={wholeMember.id} fidelity={wholeMember.fidelity} source={wholeMember.source} />
|
|
1003
|
+
<View style={styles.disclosureSpacer}>
|
|
1004
|
+
<MemberDisclosureToggle member={wholeMember} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
|
|
1005
|
+
</View>
|
|
1006
|
+
</>
|
|
1007
|
+
);
|
|
1008
|
+
})()}
|
|
1009
|
+
</View>
|
|
1010
|
+
)}
|
|
1011
|
+
|
|
1012
|
+
<FamilyLabeledGroup
|
|
1013
|
+
label="Parts"
|
|
1014
|
+
sections={partSections}
|
|
1015
|
+
outline={family.outline}
|
|
1016
|
+
byId={byId}
|
|
1017
|
+
allMemberIds={allMemberIds}
|
|
1018
|
+
defaultPreviewWidths={defaultPreviewWidths}
|
|
1019
|
+
apiLabel={apiLabel}
|
|
1020
|
+
resolvesAddress={resolvesAddress}
|
|
1021
|
+
openRows={openRows}
|
|
1022
|
+
onToggleRow={toggleRow}
|
|
1023
|
+
registerNode={registerNode}
|
|
1024
|
+
/>
|
|
1025
|
+
<FamilyLabeledGroup
|
|
1026
|
+
label="Related"
|
|
1027
|
+
sections={relatedSections}
|
|
1028
|
+
outline={family.outline}
|
|
1029
|
+
byId={byId}
|
|
1030
|
+
allMemberIds={allMemberIds}
|
|
1031
|
+
defaultPreviewWidths={defaultPreviewWidths}
|
|
1032
|
+
apiLabel={apiLabel}
|
|
1033
|
+
resolvesAddress={resolvesAddress}
|
|
1034
|
+
openRows={openRows}
|
|
1035
|
+
onToggleRow={toggleRow}
|
|
1036
|
+
registerNode={registerNode}
|
|
1037
|
+
/>
|
|
1038
|
+
</View>
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const PART_GAP = 64;
|
|
1043
|
+
const LABEL_GAP = 80;
|
|
1044
|
+
const PIN_GUTTER = 52;
|
|
1045
|
+
|
|
1046
|
+
const styles = StyleSheet.create({
|
|
1047
|
+
breadcrumb: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted },
|
|
1048
|
+
pageHead: { paddingBottom: CATALOG_SPACE.xl, marginBottom: CATALOG_SPACE['2xl'], borderBottomWidth: 1, borderBottomColor: CATALOG_COLOR.borderStrong },
|
|
1049
|
+
titlebar: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: CATALOG_SPACE.xl, marginTop: 14 },
|
|
1050
|
+
titleText: { flex: 1 },
|
|
1051
|
+
headingTarget: { alignSelf: 'flex-start' },
|
|
1052
|
+
title: { ...CATALOG_TEXT.pageTitle, color: CATALOG_COLOR.text, marginBottom: 5 },
|
|
1053
|
+
desc: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
|
|
1054
|
+
notShown: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, maxWidth: 700, marginTop: CATALOG_SPACE.sm },
|
|
1055
|
+
notShownLead: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
|
|
1056
|
+
|
|
1057
|
+
// Slice D1 item 5: the anatomy box's own "Anatomy" label sits outside, above a plain card —
|
|
1058
|
+
// the same treatment the specimen grids below it use (SectionBlock's blockLabel and
|
|
1059
|
+
// ComparisonList's card).
|
|
1060
|
+
anatomy: { marginTop: CATALOG_SPACE.xl, marginBottom: CATALOG_LAYOUT.blockGap },
|
|
1061
|
+
anatomyCard: { backgroundColor: CATALOG_COLOR.surface, borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, borderRadius: CATALOG_RADIUS.card, overflow: 'hidden' },
|
|
1062
|
+
anatomyHeadText: { ...CATALOG_TEXT.label, color: CATALOG_COLOR.text, marginBottom: CATALOG_LAYOUT.blockLabelGap },
|
|
1063
|
+
anatomyBody: { flexDirection: 'row', alignItems: 'flex-start' },
|
|
1064
|
+
anatomyStage: { padding: CATALOG_SPACE.xl, alignItems: 'center', justifyContent: 'center' },
|
|
1065
|
+
anatomyLegend: { flex: 1, borderLeftWidth: 1, borderLeftColor: CATALOG_COLOR.border, paddingVertical: CATALOG_SPACE.sm },
|
|
1066
|
+
anatomyColumns: { flexDirection: 'row', borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
|
|
1067
|
+
anatomyColumn: { flex: 1, borderLeftWidth: 1, borderLeftColor: CATALOG_COLOR.border, paddingVertical: CATALOG_SPACE.sm },
|
|
1068
|
+
// Host-authored text (`anatomy.groups[].label`) — never a transforming role like `microLabel`,
|
|
1069
|
+
// so the author's own casing survives; see tokens.ts's role table.
|
|
1070
|
+
legendGroupLabel: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.textMuted, paddingHorizontal: CATALOG_SPACE.lg, paddingTop: CATALOG_SPACE.md, paddingBottom: CATALOG_SPACE.xs },
|
|
1071
|
+
legendRow: { flexDirection: 'row', alignItems: 'flex-start', gap: CATALOG_SPACE.sm, paddingHorizontal: CATALOG_SPACE.lg, paddingVertical: CATALOG_SPACE.sm, borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
|
|
1072
|
+
legendPinSlot: { width: 34, alignItems: 'center' },
|
|
1073
|
+
legendBody: { flex: 1, gap: 2 },
|
|
1074
|
+
legendNameRow: { flexDirection: 'row', alignItems: 'baseline', gap: CATALOG_SPACE.xs },
|
|
1075
|
+
legendName: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
|
|
1076
|
+
legendDesc: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted },
|
|
1077
|
+
legendExt: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.textMuted },
|
|
1078
|
+
extPin: { minWidth: 22, height: 22, paddingHorizontal: 6, borderRadius: 11, borderWidth: 1.5, borderColor: CATALOG_COLOR.accent, backgroundColor: CATALOG_COLOR.surface, alignItems: 'center', justifyContent: 'center' },
|
|
1079
|
+
extPinText: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.accent },
|
|
1080
|
+
|
|
1081
|
+
// Slice D1 item 7: a filled accent pin; dashed when the member has no area of its own
|
|
1082
|
+
// (documentation-only).
|
|
1083
|
+
pin: { minWidth: 22, height: 22, paddingHorizontal: 6, borderRadius: 11, backgroundColor: CATALOG_COLOR.accent, alignItems: 'center', justifyContent: 'center' },
|
|
1084
|
+
pinDashed: { backgroundColor: CATALOG_COLOR.surface, borderWidth: 1.5, borderColor: CATALOG_COLOR.accent, borderStyle: 'dashed' },
|
|
1085
|
+
pinText: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.surface },
|
|
1086
|
+
pinTextDashed: { color: CATALOG_COLOR.accent },
|
|
1087
|
+
|
|
1088
|
+
// Same body rhythm as a part (`partBody`), so the whole's disclosure sits below its line too.
|
|
1089
|
+
whole: { marginTop: CATALOG_SPACE.xl, gap: CATALOG_SPACE.sm },
|
|
1090
|
+
|
|
1091
|
+
partHeadRow: { flexDirection: 'row', alignItems: 'center' },
|
|
1092
|
+
// Slice D1 item 7: the fixed 52px gutter for a Part's pin and tree line.
|
|
1093
|
+
pinBox: { width: PIN_GUTTER, alignItems: 'flex-start' },
|
|
1094
|
+
partHeadMain: { flex: 1 },
|
|
1095
|
+
// D2 item A1: the body's own rhythm gap applies to every member (numbered or not); only the
|
|
1096
|
+
// gutter indent is conditional on `numbered`, so a Related member (never numbered) still gets
|
|
1097
|
+
// the same spacing as a numbered Part's own body.
|
|
1098
|
+
partBody: { gap: CATALOG_SPACE.sm },
|
|
1099
|
+
partBodyIndented: { marginLeft: PIN_GUTTER },
|
|
1100
|
+
|
|
1101
|
+
// Slice D1 item 7 "Tree line": a child's whole block (its pin and all) sits one more 52px gutter
|
|
1102
|
+
// right of its parent, so the parent's own pin-to-body gutter and the child's nesting read as
|
|
1103
|
+
// the same 52px step; the elbow itself is drawn in `Branch`'s own measured absolute overlay.
|
|
1104
|
+
branch: { position: 'relative' },
|
|
1105
|
+
subparts: { marginTop: PART_GAP, marginLeft: PIN_GUTTER, gap: PART_GAP },
|
|
1106
|
+
treeLine: { position: 'absolute', borderLeftWidth: 2, borderBottomWidth: 2, borderColor: CATALOG_COLOR.treeLine, borderBottomLeftRadius: 24 },
|
|
1107
|
+
|
|
1108
|
+
memberHead: { flexDirection: 'row', alignItems: 'baseline', gap: CATALOG_SPACE.sm, flexWrap: 'wrap' },
|
|
1109
|
+
memberName: { color: CATALOG_COLOR.text },
|
|
1110
|
+
memberDesc: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, maxWidth: 700, marginTop: CATALOG_SPACE.xs },
|
|
1111
|
+
// V2b item 5: `partBody`'s own gap already contributes CATALOG_SPACE.sm after this wrapper; this
|
|
1112
|
+
// adds just enough (lg - sm) on top of it so the total gap to the next line is exactly
|
|
1113
|
+
// CATALOG_SPACE.lg, instead of double-stacking with `Blocks`' own page-level marginBottom.
|
|
1114
|
+
memberSpecimens: { marginTop: CATALOG_SPACE.sm, marginBottom: CATALOG_SPACE.lg - CATALOG_SPACE.sm },
|
|
1115
|
+
belowLine: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
|
|
1116
|
+
relatedLink: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.accent },
|
|
1117
|
+
wholeBelowRow: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.xs, marginTop: CATALOG_SPACE.sm, flexWrap: 'wrap' },
|
|
1118
|
+
wholeName: { ...CATALOG_TEXT.codeSmallStrong, color: CATALOG_COLOR.text },
|
|
1119
|
+
// V2d item 3: v8's own `.uses` line, inside the disclosure.
|
|
1120
|
+
usesLine: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.text, maxWidth: 700 },
|
|
1121
|
+
usesCode: { ...CATALOG_TEXT.codeSmall, color: CATALOG_COLOR.text },
|
|
1122
|
+
|
|
1123
|
+
badge: {
|
|
1124
|
+
alignSelf: 'center',
|
|
1125
|
+
paddingHorizontal: CATALOG_SPACE.sm,
|
|
1126
|
+
paddingVertical: 3,
|
|
1127
|
+
borderRadius: CATALOG_RADIUS.control,
|
|
1128
|
+
borderWidth: 1,
|
|
1129
|
+
borderColor: CATALOG_COLOR.borderStrong,
|
|
1130
|
+
backgroundColor: CATALOG_COLOR.surface,
|
|
1131
|
+
},
|
|
1132
|
+
badgeMuted: { backgroundColor: CATALOG_COLOR.surfaceMuted },
|
|
1133
|
+
badgeText: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.text },
|
|
1134
|
+
badgeTextMuted: { color: CATALOG_COLOR.textMuted },
|
|
1135
|
+
|
|
1136
|
+
// V2c item 8: the same visual `SectionBlock`'s own page-level OS badge uses.
|
|
1137
|
+
osBadge: {
|
|
1138
|
+
alignSelf: 'center',
|
|
1139
|
+
paddingHorizontal: CATALOG_SPACE.sm,
|
|
1140
|
+
paddingVertical: 3,
|
|
1141
|
+
borderRadius: CATALOG_RADIUS.control,
|
|
1142
|
+
borderWidth: 1,
|
|
1143
|
+
borderColor: CATALOG_COLOR.borderStrong,
|
|
1144
|
+
backgroundColor: CATALOG_COLOR.surface,
|
|
1145
|
+
},
|
|
1146
|
+
osBadgeText: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.text },
|
|
1147
|
+
|
|
1148
|
+
// V2c item 4: the same `focused && styles.focusRing` pattern every other catalog control uses
|
|
1149
|
+
// (`PagerButton`, `CatalogSidebar`'s nav/footer links) — outline-based, so focusing never shifts
|
|
1150
|
+
// layout.
|
|
1151
|
+
focusRing: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing },
|
|
1152
|
+
|
|
1153
|
+
// Slice D1 item 9: "Notes and API" is now 12/18 regular in the accent color, 4px under the line
|
|
1154
|
+
// below the specimens — `partBody`'s own CATALOG_SPACE.sm gap already contributes 8px, so this
|
|
1155
|
+
// subtracts the difference (sm - xs = 4) the same way `memberSpecimens` does above.
|
|
1156
|
+
disclosureSpacer: { marginTop: CATALOG_SPACE.xs - CATALOG_SPACE.sm },
|
|
1157
|
+
disclosureToggle: {
|
|
1158
|
+
flexDirection: 'row',
|
|
1159
|
+
alignItems: 'center',
|
|
1160
|
+
gap: CATALOG_SPACE.xs,
|
|
1161
|
+
alignSelf: 'flex-start',
|
|
1162
|
+
minHeight: CATALOG_LAYOUT.controlSize,
|
|
1163
|
+
justifyContent: 'center',
|
|
1164
|
+
},
|
|
1165
|
+
chevron: { ...CATALOG_TEXT.icon, color: CATALOG_COLOR.accent },
|
|
1166
|
+
disclosureToggleLabel: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.accent },
|
|
1167
|
+
disclosureBody: { gap: CATALOG_SPACE.sm, paddingTop: CATALOG_SPACE.xs },
|
|
1168
|
+
note: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.text },
|
|
1169
|
+
disclosureText: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
|
|
1170
|
+
disclosureLead: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
|
|
1171
|
+
apiBlock: { backgroundColor: CATALOG_COLOR.codeSurface, borderRadius: CATALOG_RADIUS.md, padding: CATALOG_SPACE.md, marginTop: CATALOG_SPACE.xs },
|
|
1172
|
+
apiText: { ...CATALOG_TEXT.codeSmall, color: CATALOG_COLOR.codeOnSurface },
|
|
1173
|
+
|
|
1174
|
+
// Slice D1 item 4: "Parts"/"Related" are the only two section labels — 80px above each, 64px
|
|
1175
|
+
// between each authored section's own group within a bucket.
|
|
1176
|
+
labeledGroup: { marginTop: LABEL_GAP, paddingTop: CATALOG_SPACE.xl, borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
|
|
1177
|
+
sectionLabel: { ...CATALOG_TEXT.label, color: CATALOG_COLOR.textMuted, marginBottom: CATALOG_SPACE.lg },
|
|
1178
|
+
labeledGroupBody: { gap: PART_GAP },
|
|
1179
|
+
blocksSection: { gap: PART_GAP },
|
|
1180
|
+
|
|
1181
|
+
foldedList: { backgroundColor: CATALOG_COLOR.surface, borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, borderRadius: CATALOG_RADIUS.card, overflow: 'hidden' },
|
|
1182
|
+
foldedListRow: { paddingHorizontal: CATALOG_SPACE.lg, paddingVertical: CATALOG_SPACE.md },
|
|
1183
|
+
foldedListRowDivider: { borderBottomWidth: 1, borderBottomColor: CATALOG_COLOR.border },
|
|
1184
|
+
foldedRow: { gap: CATALOG_SPACE.xs },
|
|
1185
|
+
foldedRowToggle: { minHeight: CATALOG_LAYOUT.controlSize, justifyContent: 'center' },
|
|
1186
|
+
foldedRowHead: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.sm },
|
|
1187
|
+
foldedRowBody: { gap: CATALOG_SPACE.sm, paddingTop: CATALOG_SPACE.sm },
|
|
1188
|
+
});
|