@krapwoo/ds-viewer 0.14.2 → 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.
@@ -0,0 +1,230 @@
1
+ import React, { useEffect, useMemo, useRef } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import { CATALOG_SPACE } from './tokens';
4
+ import type { SectionPager } from './SectionBlock';
5
+ import { hashForId } from './catalogNavigation';
6
+ import { referenceLinkProps, type ReferenceNavigation } from './catalogReferences';
7
+ import { JumpRow, type JumpItem } from './JumpRow';
8
+ import { AnatomyBox, FamilyPageHead, FamilyPart, MemberPanelHost, PartOfLine, PartTreeBranch, scrollAndFocusMember, type FamilyMember } from './FamilyPage';
9
+ import { buildPartTree } from './familyModel';
10
+ import type { CatalogAnatomy, FamilyDef, PreviewWidths, SectionDef } from './types';
11
+
12
+ /** #63 R2: the same "resolve, then build a real link" `FamilyPage.tsx`'s own module-private
13
+ * `familyLink` already implements for a same-family member address — duplicated here as a small
14
+ * function (rather than exporting that private helper) since a group page's own jump chip is the
15
+ * only call site that needs it outside that file. */
16
+ function memberLink(
17
+ navigation: ReferenceNavigation | undefined,
18
+ resolvesAddress: (name: string) => boolean,
19
+ memberId: string,
20
+ ): { href: string; onPress?: (event?: { preventDefault?: () => void }) => void } | undefined {
21
+ if (navigation) {
22
+ const activation = referenceLinkProps(navigation, memberId);
23
+ return activation ? { href: hashForId(activation.memberId ?? activation.pageId), onPress: activation.onPress } : undefined;
24
+ }
25
+ return resolvesAddress(memberId) ? { href: hashForId(memberId) } : undefined;
26
+ }
27
+
28
+ type GroupMember = FamilyMember & { partOf?: { pageId: string; title: string; number: string }; anatomy?: CatalogAnatomy };
29
+
30
+ /** #63 R2: one top-level member's own section — its name row/specimens/disclosure (`FamilyPart`,
31
+ * unnumbered, the same piece a family's own Related member uses), its own "Part N of <Family>"
32
+ * link when it's an external part, its own embedded-family anatomy box (if any), and its own
33
+ * parts nested underneath (the same `PartTreeBranch` tree a family's Parts section renders). Its
34
+ * own component (rather than inline in `GroupPage`'s own `.map()`) so its tree-line `pinNodesRef`
35
+ * is a fresh hook per member, the same reason `FamilySectionGroup` is its own component in
36
+ * `FamilyPage.tsx`. */
37
+ function GroupTopLevelMember({
38
+ member, byId, outline, descendantIds, allMemberIds, defaultPreviewWidths, apiLabel, resolvesAddress, navigation, registerNode,
39
+ }: {
40
+ member: GroupMember;
41
+ byId: ReadonlyMap<string, FamilyMember>;
42
+ outline: FamilyDef['outline'];
43
+ /** Every one of this member's own descendants on the page (not only its direct children) — the
44
+ * same full-subtree input `buildPartTree` expects (`FamilySectionGroup`'s own precedent). */
45
+ descendantIds: readonly string[];
46
+ allMemberIds: readonly string[];
47
+ defaultPreviewWidths?: PreviewWidths;
48
+ apiLabel: string;
49
+ resolvesAddress: (name: string) => boolean;
50
+ navigation?: ReferenceNavigation;
51
+ registerNode: (id: string, node: View | null) => void;
52
+ }) {
53
+ const pinNodesRef = useRef<Map<string, View | null>>(new Map());
54
+ const tree = useMemo(() => buildPartTree(descendantIds, outline), [descendantIds, outline]);
55
+ return (
56
+ <View>
57
+ <FamilyPart
58
+ member={member}
59
+ number={undefined}
60
+ level={1}
61
+ allMemberIds={allMemberIds}
62
+ defaultPreviewWidths={defaultPreviewWidths}
63
+ apiLabel={apiLabel}
64
+ resolvesAddress={resolvesAddress}
65
+ navigation={navigation}
66
+ registerNode={registerNode}
67
+ afterNameRow={member.partOf ? <PartOfLine partOf={member.partOf} resolvesAddress={resolvesAddress} navigation={navigation} /> : undefined}
68
+ />
69
+ {member.anatomy && <AnatomyBox anatomy={member.anatomy} byId={byId} outline={outline} resolvesAddress={resolvesAddress} navigation={navigation} />}
70
+ {tree.length > 0 && (
71
+ <View style={styles.subparts}>
72
+ {tree.map((node) => (
73
+ <PartTreeBranch
74
+ key={node.id}
75
+ node={node}
76
+ byId={byId}
77
+ outline={outline}
78
+ allMemberIds={allMemberIds}
79
+ defaultPreviewWidths={defaultPreviewWidths}
80
+ apiLabel={apiLabel}
81
+ resolvesAddress={resolvesAddress}
82
+ navigation={navigation}
83
+ registerNode={registerNode}
84
+ pinNodesRef={pinNodesRef}
85
+ />
86
+ ))}
87
+ </View>
88
+ )}
89
+ </View>
90
+ );
91
+ }
92
+
93
+ /**
94
+ * A group page (#63 A9/R2, layout B): the same page head a family page renders, a jump row (one
95
+ * chip per top-level member), then one section per top-level member in authored order — no Compare
96
+ * block, no page-level whole. Every visual piece is reused from `FamilyPage.tsx`; this file only
97
+ * decides which members are top-level and how their own parts nest under them.
98
+ */
99
+ export function GroupPage<TId extends string>({
100
+ def,
101
+ groupLabel,
102
+ breadcrumbRoot,
103
+ banner,
104
+ pager,
105
+ headingRef,
106
+ defaultPreviewWidths,
107
+ /** Spec §5.3 "Member address", same meaning as `FamilyPage`'s own prop: the member to scroll to
108
+ * and focus once this page has rendered. */
109
+ focusMemberId,
110
+ focusNonce,
111
+ knownAddressIds,
112
+ navigation,
113
+ }: {
114
+ def: SectionDef<TId>;
115
+ groupLabel?: string;
116
+ breadcrumbRoot?: string;
117
+ banner?: React.ReactNode;
118
+ pager?: SectionPager<TId>;
119
+ headingRef?: React.Ref<View>;
120
+ defaultPreviewWidths?: PreviewWidths;
121
+ focusMemberId?: string;
122
+ focusNonce?: number;
123
+ knownAddressIds?: ReadonlySet<string>;
124
+ navigation?: ReferenceNavigation<TId>;
125
+ }) {
126
+ const family = def.family!;
127
+ const apiLabel = family.apiLabel ?? 'API';
128
+ const byId = useMemo(() => new Map(family.members.map((member) => [member.id, member])), [family.members]);
129
+ const allMemberIds = useMemo(() => family.members.map((member) => member.id), [family.members]);
130
+ const resolvesAddress = useMemo(() => {
131
+ const ids = new Set<string>([...(knownAddressIds ?? []), ...allMemberIds]);
132
+ return (name: string) => ids.has(name);
133
+ }, [knownAddressIds, allMemberIds]);
134
+
135
+ // #63 A3: a top-level member has no parent, or a parent that isn't one of this page's own
136
+ // members (the cross-page case `validateGroup` already accepts) — authored (array) order, the
137
+ // same "no sections to scan" order every other group derivation already uses.
138
+ const topLevel = useMemo(() => family.members.filter((member) => member.parent === undefined || !byId.has(member.parent)), [family.members, byId]);
139
+
140
+ const childrenOf = useMemo(() => {
141
+ const map = new Map<string, string[]>();
142
+ for (const member of family.members) {
143
+ if (member.parent !== undefined && byId.has(member.parent)) map.set(member.parent, [...(map.get(member.parent) ?? []), member.id]);
144
+ }
145
+ return map;
146
+ }, [family.members, byId]);
147
+
148
+ // Every one of a top-level member's own descendants, flattened (not only its direct children) —
149
+ // both the jump row's own "+k parts" count and `GroupTopLevelMember`'s own `buildPartTree` input
150
+ // need the full subtree.
151
+ const descendantsByTopLevel = useMemo(() => {
152
+ const descendantsOf = (id: string): string[] => {
153
+ const kids = childrenOf.get(id) ?? [];
154
+ return [...kids, ...kids.flatMap(descendantsOf)];
155
+ };
156
+ return new Map(topLevel.map((member) => [member.id, descendantsOf(member.id)]));
157
+ }, [topLevel, childrenOf]);
158
+
159
+ // A9/R2 item 2: one jump chip per top-level member — its qualifier is "Part N of <Family>" for
160
+ // an external part, "+k parts" for an embedded family with parts on this page, otherwise none.
161
+ const jumpItems: JumpItem[] = useMemo(
162
+ () =>
163
+ topLevel.map((member) => {
164
+ const link = memberLink(navigation, resolvesAddress, member.id);
165
+ const partOf = (member as GroupMember).partOf;
166
+ const descendantCount = descendantsByTopLevel.get(member.id)?.length ?? 0;
167
+ const qualifier = partOf
168
+ ? `Part ${partOf.number} of ${partOf.title}`
169
+ : descendantCount > 0
170
+ ? `+${descendantCount} part${descendantCount === 1 ? '' : 's'}`
171
+ : undefined;
172
+ return { id: member.id, title: member.id, qualifier, href: link?.href, onPress: link?.onPress };
173
+ }),
174
+ [topLevel, navigation, resolvesAddress, descendantsByTopLevel],
175
+ );
176
+
177
+ const nodesRef = useRef<Map<string, View | null>>(new Map());
178
+ const registerNode = (id: string, node: View | null) => {
179
+ if (node) nodesRef.current.set(id, node);
180
+ else nodesRef.current.delete(id);
181
+ };
182
+
183
+ // No folded (`list`-kind) rows exist on a group page (layout B: every top-level member and its
184
+ // own parts render as blocks) — unlike `FamilyPage`'s own two-phase jump, a member address here
185
+ // is always already laid out, so one scroll/focus pass per activation is enough.
186
+ useEffect(() => {
187
+ if (!focusMemberId) return;
188
+ const node = nodesRef.current.get(focusMemberId);
189
+ if (node) scrollAndFocusMember(node);
190
+ }, [focusMemberId, focusNonce]);
191
+
192
+ return (
193
+ <MemberPanelHost>
194
+ <View>
195
+ <FamilyPageHead kind="group" def={def} groupLabel={groupLabel} breadcrumbRoot={breadcrumbRoot} banner={banner} pager={pager} headingRef={headingRef} notShown={family.notShown} />
196
+
197
+ {jumpItems.length > 0 && <JumpRow items={jumpItems} />}
198
+
199
+ <View style={styles.members}>
200
+ {topLevel.map((member) => (
201
+ <GroupTopLevelMember
202
+ key={member.id}
203
+ member={member as GroupMember}
204
+ byId={byId}
205
+ outline={family.outline}
206
+ descendantIds={descendantsByTopLevel.get(member.id) ?? []}
207
+ allMemberIds={allMemberIds}
208
+ defaultPreviewWidths={defaultPreviewWidths}
209
+ apiLabel={apiLabel}
210
+ resolvesAddress={resolvesAddress}
211
+ navigation={navigation}
212
+ registerNode={registerNode}
213
+ />
214
+ ))}
215
+ </View>
216
+ </View>
217
+ </MemberPanelHost>
218
+ );
219
+ }
220
+
221
+ const MEMBER_GAP = 64;
222
+ const PIN_GUTTER = 52;
223
+
224
+ const styles = StyleSheet.create({
225
+ // Same rhythm as `FamilyPage`'s own "Parts"/"Related" group body (`labeledGroupBody`) — one
226
+ // top-level member's section to the next.
227
+ members: { marginTop: CATALOG_SPACE.xl, gap: MEMBER_GAP },
228
+ // Same nesting gutter `FamilyPage`'s own `subparts` style uses for a Part's own children.
229
+ subparts: { marginTop: MEMBER_GAP, marginLeft: PIN_GUTTER, gap: MEMBER_GAP },
230
+ });
@@ -0,0 +1,81 @@
1
+ import React, { useState } from 'react';
2
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
4
+
5
+ /** #64 B2 / #63 R1: one "On this page" jump chip, shared by a `tokenDefinition` page's category
6
+ * row and a group page's top-level-member row. `qualifier` is the small muted text after the
7
+ * title (a token page's own entry count, or a group page's "Part N of <Family>"/"+k parts").
8
+ * Exactly one of `href` (a real, keyboard-reachable anchor to the target's address — `role="link"`)
9
+ * or `onPress` (an in-page scroll, `role="button"`) is the common case; both may be set together
10
+ * (a real link that also runs its own handler, the same convention every other canonical link in
11
+ * this codebase already uses — see `catalogReferences.ts`'s `referenceLinkProps`). */
12
+ export interface JumpItem {
13
+ /** React key — the chip's own target id (a token section id, or a group member id). */
14
+ id: string;
15
+ title: string;
16
+ qualifier?: string;
17
+ /** Overrides the default accessible name (`title`, or `"<title>, <qualifier>"` when a qualifier
18
+ * is set) — a token page's own chip keeps its established "N value(s)" wording this way. */
19
+ accessibilityLabel?: string;
20
+ href?: string;
21
+ onPress?: (event?: { preventDefault?: () => void; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; button?: number }) => void;
22
+ }
23
+
24
+ export function JumpChip({ title, qualifier, accessibilityLabel, href, onPress }: Omit<JumpItem, 'id'>) {
25
+ const [hovered, setHovered] = useState(false);
26
+ const [focused, setFocused] = useState(false);
27
+ return (
28
+ <Pressable
29
+ onPress={onPress}
30
+ onHoverIn={() => setHovered(true)}
31
+ onHoverOut={() => setHovered(false)}
32
+ onFocus={() => setFocused(true)}
33
+ onBlur={() => setFocused(false)}
34
+ accessibilityRole={href ? 'link' : 'button'}
35
+ accessibilityLabel={accessibilityLabel ?? (qualifier ? `${title}, ${qualifier}` : undefined)}
36
+ // `href` renders a real `<a href="#Id">` on web (react-native-web leaves keyboard activation
37
+ // of role="link" to the browser's own anchor handling) — the same convention
38
+ // `catalogReferences.ts`'s link producers already establish.
39
+ {...(href ? ({ href } as Record<string, unknown>) : null)}
40
+ style={({ pressed }) => [styles.jumpChip, (pressed || hovered) && styles.jumpChipHover, focused && styles.focusRing]}
41
+ >
42
+ <Text style={styles.jumpChipTitle}>{title}</Text>
43
+ {qualifier ? <Text style={styles.jumpChipCount}>{qualifier}</Text> : null}
44
+ </Pressable>
45
+ );
46
+ }
47
+
48
+ /** #64 B2: a token page's category jump row, and (#63 R1) a group page's top-level-member jump
49
+ * row — one chip per item, in authored order. */
50
+ export function JumpRow({ items }: { items: readonly JumpItem[] }) {
51
+ return (
52
+ <View style={styles.jumpRow} accessibilityRole="list">
53
+ {/* `role="list"` must own `listitem` children, so each chip sits in its own item. */}
54
+ {items.map((item) => (
55
+ <View key={item.id} role="listitem">
56
+ <JumpChip title={item.title} qualifier={item.qualifier} accessibilityLabel={item.accessibilityLabel} href={item.href} onPress={item.onPress} />
57
+ </View>
58
+ ))}
59
+ </View>
60
+ );
61
+ }
62
+
63
+ const styles = StyleSheet.create({
64
+ // #64 B2: the jump row's own chip/pill look — the catalog's existing chip/pill visual
65
+ // (`CATALOG_COLOR.chip`, the same muted rounded pill `UpdatePanel.tsx`'s own status pills use),
66
+ // never a new visual.
67
+ jumpRow: { flexDirection: 'row', flexWrap: 'wrap', gap: CATALOG_SPACE.sm, marginBottom: CATALOG_LAYOUT.blockLabelGap },
68
+ jumpChip: {
69
+ flexDirection: 'row',
70
+ alignItems: 'center',
71
+ gap: CATALOG_SPACE.xs,
72
+ paddingHorizontal: CATALOG_SPACE.sm,
73
+ paddingVertical: 4,
74
+ borderRadius: CATALOG_RADIUS.control,
75
+ backgroundColor: CATALOG_COLOR.chip,
76
+ },
77
+ jumpChipHover: { backgroundColor: CATALOG_COLOR.surfacePressed },
78
+ jumpChipTitle: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
79
+ jumpChipCount: { ...CATALOG_TEXT.micro, color: CATALOG_COLOR.textMuted },
80
+ focusRing: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing },
81
+ });
@@ -3,7 +3,22 @@ import { View, Text, StyleSheet, type LayoutChangeEvent, type TextStyle } from '
3
3
  import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
4
4
  import { PropsTable } from './PropsTable';
5
5
  import { propsColumns, referenceColumns } from './comparison';
6
- import type { ComposedOfEntry, SectionDef } from './types';
6
+ import { hashForId } from './catalogNavigation';
7
+ import { referenceLinkProps, type ReferenceNavigation } from './catalogReferences';
8
+ import { alsoUsedBySegments } from './familyModel';
9
+ import type { AlsoUsedByEntry, ComposedOfEntry, SectionDef } from './types';
10
+
11
+ /** `referenceLinkProps` returns the resolved target, not a ready-made `href` (see its own doc
12
+ * comment in `catalogReferences.ts`) — this builds the real link props every `Text role="link"`
13
+ * below actually spreads, the one place in this file that turns a target into `hashForId(memberId
14
+ * ?? pageId)`. */
15
+ function linkProps<TId extends string>(
16
+ navigation: ReferenceNavigation<TId> | undefined,
17
+ name: string,
18
+ ): { href: string; onPress: (event?: { preventDefault?: () => void }) => void } | undefined {
19
+ const activation = referenceLinkProps(navigation, name);
20
+ return activation ? { href: hashForId(activation.memberId ?? activation.pageId), onPress: activation.onPress } : undefined;
21
+ }
7
22
 
8
23
  // react-native-web reads `aria-level`; React Native's prop types do not declare it.
9
24
  const HEADING_LEVEL_2 = { 'aria-level': 2 } as Record<string, unknown>;
@@ -46,18 +61,57 @@ function PropsBox<TId extends string>({ def }: { def: SectionDef<TId> }) {
46
61
  // `built-in` is composition's assumed default relationship, so spelling it out reads redundantly
47
62
  // (e.g. "TrainArrivalRemainingTime · built-in"); `slot`/`related` still need their own suffix to
48
63
  // stay distinguishable from a plain built-in entry.
49
- function compositionLabel(entry: ComposedOfEntry): string {
50
- return entry.relationship === 'built-in' ? entry.component : `${entry.component} · ${entry.relationship}`;
64
+ function compositionSuffix(entry: ComposedOfEntry): string {
65
+ return entry.relationship === 'built-in' ? '' : ` · ${entry.relationship}`;
66
+ }
67
+
68
+ /** One Composition row: the name links to its real canonical address when `navigation` resolves it
69
+ * (plan #60: "Ordinary Composition references become real links when resolvable … unresolved
70
+ * names remain honest plain text") — a relationship suffix, when any, stays outside the link, the
71
+ * same way `UsesLine` in `FamilyPage.tsx` keeps its own `(role)` text unlinked. `navigation`
72
+ * omitted keeps the prior plain-text rendering untouched (a standalone-mounted `ReferenceDetails`,
73
+ * this framework's own catalog demo included). */
74
+ function CompositionFact<TId extends string>({
75
+ entry,
76
+ navigation,
77
+ last = false,
78
+ }: {
79
+ entry: ComposedOfEntry;
80
+ navigation?: ReferenceNavigation<TId>;
81
+ last?: boolean;
82
+ }) {
83
+ const link = linkProps(navigation, entry.component);
84
+ return (
85
+ <View style={[styles.fact, last && styles.factLast]}>
86
+ <Text style={styles.factLabel}>
87
+ {link ? (
88
+ <Text role="link" style={styles.factLabelLink} {...link}>{entry.component}</Text>
89
+ ) : (
90
+ entry.component
91
+ )}
92
+ {compositionSuffix(entry)}
93
+ </Text>
94
+ <Text style={styles.factValue}>{entry.role}</Text>
95
+ </View>
96
+ );
51
97
  }
52
98
 
53
- function CompositionFacts({ composedOf, lastIsFlush = false }: { composedOf: ComposedOfEntry[]; lastIsFlush?: boolean }) {
99
+ function CompositionFacts<TId extends string>({
100
+ composedOf,
101
+ lastIsFlush = false,
102
+ navigation,
103
+ }: {
104
+ composedOf: ComposedOfEntry[];
105
+ lastIsFlush?: boolean;
106
+ navigation?: ReferenceNavigation<TId>;
107
+ }) {
54
108
  return (
55
109
  <>
56
110
  {composedOf.map((entry, i) => (
57
- <Fact
111
+ <CompositionFact
58
112
  key={`${entry.component}-${entry.relationship}-${i}`}
59
- label={compositionLabel(entry)}
60
- value={entry.role}
113
+ entry={entry}
114
+ navigation={navigation}
61
115
  last={lastIsFlush && i === composedOf.length - 1}
62
116
  />
63
117
  ))}
@@ -65,6 +119,56 @@ function CompositionFacts({ composedOf, lastIsFlush = false }: { composedOf: Com
65
119
  );
66
120
  }
67
121
 
122
+ /** The page's own `notes` as one Quick reference entry — one bullet per note, the same `• ` style
123
+ * a family member's notes use in its side panel. */
124
+ function NotesFact({ notes, last = false }: { notes: readonly string[]; last?: boolean }) {
125
+ return (
126
+ <View style={[styles.fact, last && styles.factLast]}>
127
+ <Text style={styles.factLabel}>Notes</Text>
128
+ {notes.map((note, i) => (
129
+ <Text key={i} style={styles.factValue}>
130
+ {'• '}
131
+ {note}
132
+ </Text>
133
+ ))}
134
+ </View>
135
+ );
136
+ }
137
+
138
+ /** The derived "Also used by …" row (`SectionDef.alsoUsedBy`, plan #60's ordinary-page inverse of
139
+ * `FamilyDef.members[].alsoUsedBy`): the exact same sentence/segment shape
140
+ * `alsoUsedBySegments`/`WholeBelowLine` already render on a family page, so an ordinary page's own
141
+ * cross-use reads identically — a resolvable user links, an unresolvable one stays honest plain
142
+ * text. Renders nothing for an empty list (the overwhelming majority of pages): no row, no label,
143
+ * no gap — the same "nothing renders when nothing else uses it" rule the README already documents
144
+ * for a family member. */
145
+ function AlsoUsedByFact<TId extends string>({
146
+ entries,
147
+ navigation,
148
+ last = false,
149
+ }: {
150
+ entries: readonly AlsoUsedByEntry[];
151
+ navigation?: ReferenceNavigation<TId>;
152
+ last?: boolean;
153
+ }) {
154
+ const segments = alsoUsedBySegments(entries);
155
+ if (segments.length === 0) return null;
156
+ return (
157
+ <View style={[styles.fact, last && styles.factLast]}>
158
+ <Text style={styles.factValue}>
159
+ {segments.map((segment, i) => {
160
+ const link = segment.component ? linkProps(navigation, segment.component) : undefined;
161
+ return link ? (
162
+ <Text key={i} role="link" style={styles.factLabelLink} {...link}>{segment.text}</Text>
163
+ ) : (
164
+ <Text key={i}>{segment.text}</Text>
165
+ );
166
+ })}
167
+ </Text>
168
+ </View>
169
+ );
170
+ }
171
+
68
172
  /**
69
173
  * Always-visible reference below a page's specimens: a card with Guidance, Quick reference
70
174
  * (source path, accessibility), and — only when the page has confirmed composition to disclose —
@@ -86,26 +190,41 @@ function CompositionFacts({ composedOf, lastIsFlush = false }: { composedOf: Com
86
190
  * outer card previously fed `referenceColumns` an outer width up to ~42px wider than what the three
87
191
  * columns' content actually has, which can report a column count the inner content doesn't fit.
88
192
  */
89
- export function ReferenceDetails<TId extends string>({ def }: { def: SectionDef<TId> }) {
193
+ export function ReferenceDetails<TId extends string>({
194
+ def,
195
+ navigation,
196
+ }: {
197
+ def: SectionDef<TId>;
198
+ /** Task 2B2: `CatalogShell`'s own shared reference navigation (see `catalogReferences.ts`'s
199
+ * `ReferenceNavigation`/`referenceLinkProps`) — turns a resolvable Composition name into a real
200
+ * canonical link. Omitted (a standalone-mounted `ReferenceDetails`, this framework's own catalog
201
+ * demo included) keeps every name as it always rendered: plain text, no link. */
202
+ navigation?: ReferenceNavigation<TId>;
203
+ }) {
90
204
  const hide = def.hide ?? {};
91
205
  const [width, setWidth] = useState(0);
92
206
  const onLayout = (event: LayoutChangeEvent) => {
93
207
  const next = Math.round(event.nativeEvent.layout.width);
94
208
  if (next > 0 && next !== width) setWidth(next);
95
209
  };
210
+ const hasAlsoUsedBy = Boolean(def.alsoUsedBy && def.alsoUsedBy.length > 0);
211
+ const hasNotes = Boolean(def.notes && def.notes.length > 0);
96
212
  if (def.tokenGallery) {
97
213
  // Full-width pages with no component API (token galleries, and whole screens such as a
98
214
  // starter kit's Patterns) still disclose what they're built from when they say so.
215
+ const hasComposition = Boolean(def.composedOf && def.composedOf.length > 0);
99
216
  return (
100
217
  <View style={styles.card}>
101
218
  <Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Quick reference</Text>
102
- {def.path && <Fact label="Source" value={def.path} mono last={!(def.composedOf && def.composedOf.length > 0)} />}
103
- {def.composedOf && def.composedOf.length > 0 && (
219
+ {def.path && <Fact label="Source" value={def.path} mono last={!hasNotes && !hasComposition && !hasAlsoUsedBy} />}
220
+ {hasNotes && <NotesFact notes={def.notes!} last={!hasComposition && !hasAlsoUsedBy} />}
221
+ {hasComposition && def.composedOf && (
104
222
  <>
105
223
  <Text style={styles.subheading}>Composition</Text>
106
- <CompositionFacts composedOf={def.composedOf} lastIsFlush />
224
+ <CompositionFacts composedOf={def.composedOf} lastIsFlush={!hasAlsoUsedBy} navigation={navigation} />
107
225
  </>
108
226
  )}
227
+ {hasAlsoUsedBy && <AlsoUsedByFact entries={def.alsoUsedBy!} navigation={navigation} last />}
109
228
  </View>
110
229
  );
111
230
  }
@@ -114,6 +233,10 @@ export function ReferenceDetails<TId extends string>({ def }: { def: SectionDef<
114
233
  const stacked = columns === 1;
115
234
  const compositionIsOwnColumn = columns === 3 && hasComposition;
116
235
  const columnStyle = stacked ? styles.columnStacked : styles.column;
236
+ // "Also used by" always sits in Quick reference, after Composition when nested there too — it
237
+ // never gets its own column (plan #60: it's the always-visible inverse of an existing fact, not
238
+ // a new confirmed-data column like Composition).
239
+ const compositionNestedHere = !compositionIsOwnColumn && hasComposition;
117
240
 
118
241
  return (
119
242
  <View style={styles.stack}>
@@ -125,25 +248,27 @@ export function ReferenceDetails<TId extends string>({ def }: { def: SectionDef<
125
248
  </View>
126
249
  <View style={columnStyle}>
127
250
  <Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Quick reference</Text>
128
- {def.path && <Fact label="Source" value={def.path} mono last={hide.accessibility && !(!compositionIsOwnColumn && hasComposition)} />}
251
+ {def.path && <Fact label="Source" value={def.path} mono last={hide.accessibility && !hasNotes && !compositionNestedHere && !hasAlsoUsedBy} />}
129
252
  {!hide.accessibility && (
130
253
  <Fact
131
254
  label="Accessibility"
132
255
  value={def.a11y ?? 'No accessibility notes documented.'}
133
- last={!(!compositionIsOwnColumn && hasComposition)}
256
+ last={!hasNotes && !compositionNestedHere && !hasAlsoUsedBy}
134
257
  />
135
258
  )}
136
- {!compositionIsOwnColumn && hasComposition && def.composedOf && (
259
+ {hasNotes && <NotesFact notes={def.notes!} last={!compositionNestedHere && !hasAlsoUsedBy} />}
260
+ {compositionNestedHere && def.composedOf && (
137
261
  <>
138
262
  <Text style={styles.subheading}>Composition</Text>
139
- <CompositionFacts composedOf={def.composedOf} lastIsFlush />
263
+ <CompositionFacts composedOf={def.composedOf} lastIsFlush={!hasAlsoUsedBy} navigation={navigation} />
140
264
  </>
141
265
  )}
266
+ {hasAlsoUsedBy && <AlsoUsedByFact entries={def.alsoUsedBy!} navigation={navigation} last />}
142
267
  </View>
143
268
  {compositionIsOwnColumn && def.composedOf && (
144
269
  <View style={columnStyle}>
145
270
  <Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Composition</Text>
146
- <CompositionFacts composedOf={def.composedOf} lastIsFlush />
271
+ <CompositionFacts composedOf={def.composedOf} lastIsFlush navigation={navigation} />
147
272
  </View>
148
273
  )}
149
274
  </View>
@@ -187,6 +312,9 @@ const styles = StyleSheet.create({
187
312
  },
188
313
  factLast: { paddingBottom: 0, borderBottomWidth: 0 },
189
314
  factLabel: { flexShrink: 1, minWidth: 0, ...CATALOG_TEXT.bodyStrong, color: CATALOG_COLOR.text },
315
+ // Same accent treatment every other in-app reference link already uses (`FamilyPage.tsx`'s own
316
+ // `relatedLink`) — a linked Composition/"also used by" name reads the same way across both pages.
317
+ factLabelLink: { ...CATALOG_TEXT.bodyStrong, color: CATALOG_COLOR.accent },
190
318
  factValue: { minWidth: 0, ...CATALOG_TEXT.body, textAlign: 'left', color: CATALOG_COLOR.textMuted },
191
319
  // `wordBreak` isn't in the installed `TextStyle` type (RN's own, not RNW's — RNW's own
192
320
  // `Text/types.js` declares it; a browser applies any valid CSS property name it recognizes