@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.
- package/README.md +224 -2
- package/dist/cli/doctor.js +306 -17
- package/dist/cli/explain.js +26 -0
- package/dist/cli/staticPage.js +90 -6
- package/dist/cli/tokenDefinition.js +125 -0
- package/dist/native/catalog/catalogNavigation.js +27 -11
- package/dist/native/catalog/pageApi.js +557 -26
- package/dist/native/catalog/tokenDefinition.js +205 -0
- package/native/catalog/CatalogSearchInput.tsx +18 -3
- package/native/catalog/CatalogShell.tsx +318 -46
- package/native/catalog/CatalogSidebar.tsx +37 -1
- package/native/catalog/FamilyPage.tsx +567 -137
- package/native/catalog/GroupPage.tsx +230 -0
- package/native/catalog/JumpRow.tsx +81 -0
- package/native/catalog/ReferenceDetails.tsx +144 -16
- package/native/catalog/SectionBlock.tsx +49 -5
- package/native/catalog/SourceReferencePicker.tsx +180 -0
- package/native/catalog/StructuredTokenPage.tsx +592 -0
- package/native/catalog/Swatch.tsx +29 -2
- package/native/catalog/TokenLayouts.tsx +30 -9
- package/native/catalog/catalogNavigation.ts +30 -10
- package/native/catalog/catalogReferences.ts +212 -0
- package/native/catalog/familyModel.ts +10 -4
- package/native/catalog/index.ts +22 -0
- package/native/catalog/pageApi.ts +595 -23
- package/native/catalog/tokenDefinition.ts +430 -0
- package/native/catalog/types.ts +77 -5
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
50
|
-
return entry.relationship === 'built-in' ?
|
|
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
|
|
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
|
-
<
|
|
111
|
+
<CompositionFact
|
|
58
112
|
key={`${entry.component}-${entry.relationship}-${i}`}
|
|
59
|
-
|
|
60
|
-
|
|
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>({
|
|
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={!
|
|
103
|
-
{
|
|
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 && !
|
|
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={!
|
|
256
|
+
last={!hasNotes && !compositionNestedHere && !hasAlsoUsedBy}
|
|
134
257
|
/>
|
|
135
258
|
)}
|
|
136
|
-
{
|
|
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
|