@krapwoo/ds-viewer 0.10.0 → 0.12.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 +95 -1
- package/dist/cli/doctor.js +182 -3
- package/dist/cli/render.js +93 -0
- 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 +7 -0
- package/native/catalog/CatalogShell.tsx +206 -25
- package/native/catalog/CatalogSidebar.tsx +57 -3
- package/native/catalog/FamilyPage.tsx +1188 -0
- package/native/catalog/SectionBlock.tsx +57 -13
- 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 +5 -2
- package/native/catalog/overlayViewport.ts +30 -10
- package/native/catalog/pageApi.ts +357 -15
- package/native/catalog/tokens.ts +7 -0
- package/native/catalog/types.ts +106 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
-
import { View, Text, Pressable, StyleSheet, type LayoutChangeEvent } from 'react-native';
|
|
2
|
+
import { View, Text, Pressable, StyleSheet, type LayoutChangeEvent, type TextStyle } from 'react-native';
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
|
|
4
4
|
import { ComparisonGrid } from './ComparisonGrid';
|
|
5
5
|
import { ComparisonGroups } from './ComparisonGroups';
|
|
@@ -53,7 +53,7 @@ export interface SectionPager<TId extends string> {
|
|
|
53
53
|
labelOf?: (id: TId) => string;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
function PagerButton<TId extends string>({
|
|
56
|
+
export function PagerButton<TId extends string>({
|
|
57
57
|
direction,
|
|
58
58
|
targetId,
|
|
59
59
|
onNavigate,
|
|
@@ -209,7 +209,40 @@ function placementBlock(block: PresentationBlock, height: number): PlacementBloc
|
|
|
209
209
|
* are never recorded while side by side, so the decision never feeds on its own result; a width
|
|
210
210
|
* change returns to stacked and decides again.
|
|
211
211
|
*/
|
|
212
|
-
|
|
212
|
+
/** The previous/next pager buttons, in their row — shared by `SectionBlock` and `FamilyPage` so a
|
|
213
|
+
* family page's own page head gets the same pager chrome as every other page. */
|
|
214
|
+
export function SectionPagerRow<TId extends string>({ pager }: { pager: SectionPager<TId> }) {
|
|
215
|
+
return (
|
|
216
|
+
<View style={styles.pager}>
|
|
217
|
+
<PagerButton direction="previous" targetId={pager.previousId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
|
|
218
|
+
<PagerButton direction="next" targetId={pager.nextId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
|
|
219
|
+
</View>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The page's specimen blocks (grid, grouped rows, list, or preview), stacked or side by side —
|
|
224
|
+
* exported so `FamilyPage` can render a member's own specimens through the exact same placement
|
|
225
|
+
* logic a normal page's blocks use, rather than a second copy of it. */
|
|
226
|
+
export function Blocks<TId extends string>({
|
|
227
|
+
blocks,
|
|
228
|
+
def,
|
|
229
|
+
surface,
|
|
230
|
+
showLabels = true,
|
|
231
|
+
trimOuterMargin = false,
|
|
232
|
+
}: {
|
|
233
|
+
blocks: PresentationBlock[];
|
|
234
|
+
def: SectionDef<TId>;
|
|
235
|
+
surface: SpecimenSurfaceKind;
|
|
236
|
+
/** V2b item 4: a family member's own specimens render with no uppercase "Variants" / "States /
|
|
237
|
+
* configurations" block label — v8 has none inside a member block. A page's own `SectionBlock`
|
|
238
|
+
* keeps its labels (the default, unchanged). */
|
|
239
|
+
showLabels?: boolean;
|
|
240
|
+
/** V2b item 5: drops this component's own page-level `marginBottom` — `FamilyPage`'s own
|
|
241
|
+
* wrapper (`memberSpecimens`) supplies the exact spacing a member block needs instead, so the
|
|
242
|
+
* two never double-stack. A page's own `SectionBlock` keeps the margin (the default,
|
|
243
|
+
* unchanged). */
|
|
244
|
+
trimOuterMargin?: boolean;
|
|
245
|
+
}) {
|
|
213
246
|
const [available, setAvailable] = useState(0);
|
|
214
247
|
const [heights, setHeights] = useState<number[]>([]);
|
|
215
248
|
const [placement, setPlacement] = useState<'side' | 'stacked'>('stacked');
|
|
@@ -248,15 +281,22 @@ function Blocks<TId extends string>({ blocks, def, surface }: { blocks: Presenta
|
|
|
248
281
|
|
|
249
282
|
const side = placement === 'side';
|
|
250
283
|
return (
|
|
251
|
-
<View onLayout={onContainerLayout} style={[styles.blocks, side && styles.blocksSide]}>
|
|
284
|
+
<View onLayout={onContainerLayout} style={[styles.blocks, side && styles.blocksSide, trimOuterMargin && styles.blocksNoMargin]}>
|
|
252
285
|
{blocks.map((block, i) => (
|
|
253
286
|
<View
|
|
254
|
-
|
|
287
|
+
// A4: keyed by index, not `block.title || block.kind` — a member that sets the same
|
|
288
|
+
// `axis` on both `variants` and `states` now produces two blocks with identical titles,
|
|
289
|
+
// which would otherwise collide as React keys (as the family-section list already keys
|
|
290
|
+
// by index for the same reason — FamilyPage.tsx).
|
|
291
|
+
key={`${block.kind}-${i}`}
|
|
255
292
|
onLayout={onBlockLayout(i)}
|
|
256
293
|
// Side by side, each block fills its own half: a card always fills the column it is given.
|
|
257
294
|
style={side ? styles.blockFill : undefined}
|
|
258
295
|
>
|
|
259
|
-
{
|
|
296
|
+
{/* A3: `axis` now reaches grid/grouped blocks too (not only list), so any block kind
|
|
297
|
+
carrying one shows its (already informative) title on a family page, which otherwise
|
|
298
|
+
hides every generic block label via `showLabels={false}`. */}
|
|
299
|
+
{(showLabels && block.title) || ('axis' in block && block.axis) ? <Text style={styles.blockLabel}>{('axis' in block && block.axis) || block.title}</Text> : null}
|
|
260
300
|
<BlockContent block={block} def={def} surface={surface} />
|
|
261
301
|
</View>
|
|
262
302
|
))}
|
|
@@ -318,12 +358,7 @@ export function SectionBlock<TId extends string>({
|
|
|
318
358
|
)}
|
|
319
359
|
<Text style={styles.desc}>{def.description}</Text>
|
|
320
360
|
</View>
|
|
321
|
-
{pager &&
|
|
322
|
-
<View style={styles.pager}>
|
|
323
|
-
<PagerButton direction="previous" targetId={pager.previousId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
|
|
324
|
-
<PagerButton direction="next" targetId={pager.nextId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
|
|
325
|
-
</View>
|
|
326
|
-
)}
|
|
361
|
+
{pager && <SectionPagerRow pager={pager} />}
|
|
327
362
|
</View>
|
|
328
363
|
|
|
329
364
|
{blocks.length > 0 && <Blocks blocks={blocks} def={def} surface={surface} />}
|
|
@@ -356,7 +391,15 @@ const styles = StyleSheet.create({
|
|
|
356
391
|
backgroundColor: CATALOG_COLOR.surface,
|
|
357
392
|
},
|
|
358
393
|
osBadgeText: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.text },
|
|
359
|
-
|
|
394
|
+
// `wordBreak` isn't declared on React Native's own `TextStyle` (react-native-web's own
|
|
395
|
+
// `Text/types.js` declares it; a browser applies any valid CSS property name it recognizes
|
|
396
|
+
// regardless — same caveat ReferenceDetails.tsx's own `mono` style documents), so this is cast
|
|
397
|
+
// the same way. Without it, an unbroken page title (a component identifier with no spaces,
|
|
398
|
+
// e.g. `TranscriptVideoPreviewPlayerView`) has no space for the browser's default line-breaking
|
|
399
|
+
// to use: it stays one unbroken run at its full intrinsic width — wide enough to push past
|
|
400
|
+
// `titleText`'s own column and into the pager — instead of wrapping inside the column like any
|
|
401
|
+
// title with spaces already does.
|
|
402
|
+
title: { ...CATALOG_TEXT.pageTitle, color: CATALOG_COLOR.text, marginBottom: 5, wordBreak: 'break-word' } as TextStyle,
|
|
360
403
|
desc: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
|
|
361
404
|
pager: { flexDirection: 'row', gap: 6, flexShrink: 0 },
|
|
362
405
|
pagerButton: {
|
|
@@ -375,6 +418,7 @@ const styles = StyleSheet.create({
|
|
|
375
418
|
pagerGlyph: { ...CATALOG_TEXT.icon, color: CATALOG_COLOR.text },
|
|
376
419
|
pagerGlyphDisabled: { color: CATALOG_COLOR.textMuted },
|
|
377
420
|
blocks: { gap: CATALOG_LAYOUT.blockGap, marginBottom: CATALOG_LAYOUT.blockGap },
|
|
421
|
+
blocksNoMargin: { marginBottom: 0 },
|
|
378
422
|
blocksSide: { flexDirection: 'row', alignItems: 'flex-start' },
|
|
379
423
|
blockFill: { flex: 1, minWidth: 0 },
|
|
380
424
|
// Centred in the card, like every other example (a single phone-width frame used to sit left).
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* runner. CatalogShell, CatalogSidebar, and SectionBlock all derive page order from here, so the
|
|
4
4
|
* sidebar order, previous/next order, and fragment fallback can never drift apart.
|
|
5
5
|
*/
|
|
6
|
-
import type { NavGroup, UpdateNotice, VersionStatus } from './types.ts';
|
|
6
|
+
import type { NavGroup, SectionDef, UpdateNotice, VersionStatus } from './types.ts';
|
|
7
7
|
|
|
8
8
|
/** THE canonical within-group ordering of section ids. */
|
|
9
9
|
export function sortIds<TId extends string>(ids: readonly TId[]): TId[] {
|
|
@@ -47,18 +47,25 @@ export function neighbors<TId extends string>(
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
/** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed.
|
|
50
|
+
/** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed. A page
|
|
51
|
+
* matches on its id, its title, or (spec §5.3) any of its family's member ids or aliases. */
|
|
51
52
|
export function filterGroups<TId extends string>(
|
|
52
53
|
groups: readonly NavGroup<TId>[],
|
|
53
54
|
query: string,
|
|
54
55
|
/** Display titles by id; a page matches on its id or its title. */
|
|
55
56
|
labels?: ReadonlyMap<string, string>,
|
|
57
|
+
/** Every member id/alias a family page's members use, by page id. */
|
|
58
|
+
memberIds?: ReadonlyMap<string, readonly string[]>,
|
|
56
59
|
): NavGroup<TId>[] {
|
|
57
60
|
const q = query.trim().toLowerCase();
|
|
58
61
|
return groups
|
|
59
62
|
.map((group) => ({
|
|
60
63
|
label: group.label,
|
|
61
|
-
ids: sortIds(q ? group.ids.filter((id) =>
|
|
64
|
+
ids: sortIds(q ? group.ids.filter((id) =>
|
|
65
|
+
id.toLowerCase().includes(q)
|
|
66
|
+
|| (labels?.get(id)?.toLowerCase().includes(q) ?? false)
|
|
67
|
+
|| (memberIds?.get(id)?.some((memberId) => memberId.toLowerCase().includes(q)) ?? false),
|
|
68
|
+
) : group.ids),
|
|
62
69
|
}))
|
|
63
70
|
.filter((group) => group.ids.length > 0);
|
|
64
71
|
}
|
|
@@ -131,6 +138,85 @@ export function resolveActiveFromHash<TId extends string>(
|
|
|
131
138
|
return idFromHash(hash, order);
|
|
132
139
|
}
|
|
133
140
|
|
|
141
|
+
/** Where an alias or member id resolves: the family page `pageId`, and — unless it names the
|
|
142
|
+
* whole itself — the specific `memberId` within it. Built by `aliasTargets`. */
|
|
143
|
+
export interface AliasTarget {
|
|
144
|
+
pageId: string;
|
|
145
|
+
memberId?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Every member id other than the whole, every member alias, and every family page's own
|
|
149
|
+
* (page-level) alias, mapped to where it resolves (spec §5.3 "Old addresses"). Pages without a
|
|
150
|
+
* `family` contribute nothing. */
|
|
151
|
+
export function aliasTargets<TId extends string>(sections: readonly SectionDef<TId>[]): Map<string, AliasTarget> {
|
|
152
|
+
const targets = new Map<string, AliasTarget>();
|
|
153
|
+
for (const section of sections) {
|
|
154
|
+
const family = section.family;
|
|
155
|
+
if (!family) continue;
|
|
156
|
+
for (const member of family.members) {
|
|
157
|
+
if (member.id === family.whole) continue;
|
|
158
|
+
targets.set(member.id, { pageId: section.id, memberId: member.id });
|
|
159
|
+
for (const alias of member.aliases ?? []) targets.set(alias, { pageId: section.id, memberId: member.id });
|
|
160
|
+
}
|
|
161
|
+
for (const alias of family.aliases ?? []) targets.set(alias, { pageId: section.id });
|
|
162
|
+
}
|
|
163
|
+
return targets;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Where a URL fragment should take the viewer (spec §5.3). Unlike `resolveActiveFromHash`
|
|
167
|
+
* (kept, unchanged, for existing callers), this never silently falls back to the first page: an
|
|
168
|
+
* unknown or malformed fragment is reported as `notFound` instead. */
|
|
169
|
+
export type Route<TId extends string> =
|
|
170
|
+
| { kind: 'page'; id: TId }
|
|
171
|
+
| { kind: 'member'; pageId: TId; memberId: string }
|
|
172
|
+
| { kind: 'update' }
|
|
173
|
+
| { kind: 'notFound'; raw: string }
|
|
174
|
+
| { kind: 'empty' };
|
|
175
|
+
|
|
176
|
+
export function resolveRoute<TId extends string>(
|
|
177
|
+
hash: string,
|
|
178
|
+
order: readonly TId[],
|
|
179
|
+
aliases: ReadonlyMap<string, AliasTarget>,
|
|
180
|
+
): Route<TId> {
|
|
181
|
+
const raw = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
182
|
+
if (raw === '') return { kind: 'empty' };
|
|
183
|
+
let decoded: string;
|
|
184
|
+
try {
|
|
185
|
+
decoded = decodeURIComponent(raw);
|
|
186
|
+
} catch {
|
|
187
|
+
return { kind: 'notFound', raw };
|
|
188
|
+
}
|
|
189
|
+
if (decoded === UPDATE_PAGE_ID) return { kind: 'update' };
|
|
190
|
+
if ((order as readonly string[]).includes(decoded)) return { kind: 'page', id: decoded as TId };
|
|
191
|
+
const target = aliases.get(decoded);
|
|
192
|
+
if (target) {
|
|
193
|
+
if (target.memberId !== undefined) return { kind: 'member', pageId: target.pageId as TId, memberId: target.memberId };
|
|
194
|
+
return { kind: 'page', id: target.pageId as TId };
|
|
195
|
+
}
|
|
196
|
+
return { kind: 'notFound', raw: decoded };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One member search candidate: a family page's member, with its owning page and family title. */
|
|
200
|
+
export interface MemberSearchEntry<TId extends string> {
|
|
201
|
+
pageId: TId;
|
|
202
|
+
memberId: string;
|
|
203
|
+
aliases?: readonly string[];
|
|
204
|
+
familyTitle: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Member search hits (spec §5.3 "Search"): every entry whose member id or alias contains the
|
|
208
|
+
* query, case-insensitively. The label is always the member id — the Swift declaration name. */
|
|
209
|
+
export function searchResults<TId extends string>(
|
|
210
|
+
entries: readonly MemberSearchEntry<TId>[],
|
|
211
|
+
query: string,
|
|
212
|
+
): Array<{ pageId: TId; memberId: string; label: string; familyTitle: string }> {
|
|
213
|
+
const q = query.trim().toLowerCase();
|
|
214
|
+
if (!q) return [];
|
|
215
|
+
return entries
|
|
216
|
+
.filter((entry) => entry.memberId.toLowerCase().includes(q) || (entry.aliases ?? []).some((alias) => alias.toLowerCase().includes(q)))
|
|
217
|
+
.map((entry) => ({ pageId: entry.pageId, memberId: entry.memberId, label: entry.memberId, familyTitle: entry.familyTitle }));
|
|
218
|
+
}
|
|
219
|
+
|
|
134
220
|
/** Design §5: "Major: ... dismissible per version; the footer line remains." `dismissedVersion`
|
|
135
221
|
* is whatever a previous dismissal recorded (`CatalogShell`'s own `localStorage` read, Task 14) —
|
|
136
222
|
* any value other than the *current* `update.latest` (including `null`, or an older dismissed
|
|
@@ -250,9 +250,9 @@ export interface ListGroup {
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
export type PresentationBlock =
|
|
253
|
-
| { kind: 'grid'; title: string; size: SpecimenSize; comparison: ComparisonDef }
|
|
254
|
-
| { kind: 'grouped'; title: string; size: SpecimenSize; groups: ListGroup[] }
|
|
255
|
-
| { kind: 'list'; title: string; size: SpecimenSize; items: ListItem[]; maxColumns?: 1 | 2 | 3 | 4 | 5 }
|
|
253
|
+
| { kind: 'grid'; title: string; size: SpecimenSize; comparison: ComparisonDef; axis?: string }
|
|
254
|
+
| { kind: 'grouped'; title: string; size: SpecimenSize; groups: ListGroup[]; axis?: string }
|
|
255
|
+
| { kind: 'list'; title: string; size: SpecimenSize; items: ListItem[]; maxColumns?: 1 | 2 | 3 | 4 | 5; axis?: string }
|
|
256
256
|
| { kind: 'preview'; title: string; widths: PreviewWidths; layout?: 'frames' | 'table' }
|
|
257
257
|
| { kind: 'tokenSections'; title: string; sections: TokenSection[]; columns: 1 | 2 | 3 }
|
|
258
258
|
| { kind: 'empty'; title: string; message: string };
|
|
@@ -340,16 +340,26 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
|
|
|
340
340
|
title: `${def.comparison.rowLabel} × ${def.comparison.columnLabel}`,
|
|
341
341
|
size: def.comparison.size ?? def.specimenSize ?? 'regular',
|
|
342
342
|
comparison: def.comparison,
|
|
343
|
+
// A3: `comparison` replaces the Variants list, but `def.variants.axis` (if still set) is
|
|
344
|
+
// the only axis label a grid has to carry onto a family page, which hides generic titles.
|
|
345
|
+
...(def.variants?.axis ? { axis: def.variants.axis } : {}),
|
|
343
346
|
});
|
|
344
347
|
} else if (grouped && def.variants) {
|
|
345
348
|
// Grouped rows place several cells beside a row header, so full-width (itemsFill) slots do
|
|
346
349
|
// not force 402px cells here; a cell's own `fill` still stretches its specimen.
|
|
347
|
-
blocks.push({
|
|
350
|
+
blocks.push({
|
|
351
|
+
kind: 'grouped',
|
|
352
|
+
title: 'Variant × configuration',
|
|
353
|
+
size: def.specimenSize ?? 'regular',
|
|
354
|
+
groups: grouped.groups,
|
|
355
|
+
...(def.variants.axis ? { axis: def.variants.axis } : {}),
|
|
356
|
+
});
|
|
348
357
|
states = grouped.leftovers;
|
|
349
358
|
} else if (def.variants) {
|
|
350
359
|
blocks.push({
|
|
351
360
|
kind: 'list',
|
|
352
|
-
title: 'Variants',
|
|
361
|
+
title: def.variants.axis ?? 'Variants',
|
|
362
|
+
...(def.variants.axis ? { axis: def.variants.axis } : {}),
|
|
353
363
|
size: slotSize(def, def.variants),
|
|
354
364
|
items: slotItems(def.variants, 'variants', def.specimenSize === 'page'),
|
|
355
365
|
...slotColumnCap(def.variants),
|
|
@@ -363,7 +373,8 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
|
|
|
363
373
|
const secondary = blocks.length > 0 && blocks[0].kind !== 'list';
|
|
364
374
|
blocks.push({
|
|
365
375
|
kind: 'list',
|
|
366
|
-
title: secondary ? 'Other configurations' : 'States / configurations',
|
|
376
|
+
title: states.axis ?? (secondary ? 'Other configurations' : 'States / configurations'),
|
|
377
|
+
...(states.axis ? { axis: states.axis } : {}),
|
|
367
378
|
size: slotSize(def, states),
|
|
368
379
|
items: slotItems(states, 'states', def.specimenSize === 'page'),
|
|
369
380
|
...slotColumnCap(states),
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure family-page UI logic (spec §5.1–§5.2) — no React or React Native imports, so it runs under
|
|
3
|
+
* Node's test runner. `FamilyPage.tsx` builds on this module rather than duplicating its own tree
|
|
4
|
+
* building, disclosure content, and link/pin lookups. Outline numbering (`numberFor`) and family
|
|
5
|
+
* validation (`validateFamily`) live in `pageApi.ts` instead — see that file's own comments.
|
|
6
|
+
*/
|
|
7
|
+
import type { CatalogMember, CatalogMemberSection, PropDef } from './types.ts';
|
|
8
|
+
|
|
9
|
+
/** One node of a `blocks` section's own Part tree (Slice D1 item 7 "Tree line"). */
|
|
10
|
+
export interface PartTreeEntry {
|
|
11
|
+
id: string;
|
|
12
|
+
children: PartTreeEntry[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** E1: moved from `FamilyPage.tsx` so it runs under Node's test runner — pure logic that alone
|
|
16
|
+
* decides how a `blocks` section's own members nest and in what order its Parts render.
|
|
17
|
+
* Reconstructs parentage mechanically from the already-computed `numberFor` outline (a child's
|
|
18
|
+
* number is its parent's number plus one more `.` segment), scoped to the member ids given, not a
|
|
19
|
+
* second copy of `numberFor`'s own nesting decision. Scoping to one section's own ids (rather than
|
|
20
|
+
* every member of the page) is how family data is authored — a section's `members` already lists
|
|
21
|
+
* a full parent/children subtree together — so a parent belonging to a *different* section is
|
|
22
|
+
* simply absent from `memberIds`, and the would-be child becomes a root of this tree instead of
|
|
23
|
+
* nesting under a part this call never sees. */
|
|
24
|
+
export function buildPartTree(memberIds: readonly string[], outline: ReadonlyMap<string, { number: string; level: number }>): PartTreeEntry[] {
|
|
25
|
+
const idByNumber = new Map<string, string>();
|
|
26
|
+
for (const id of memberIds) {
|
|
27
|
+
const number = outline.get(id)?.number;
|
|
28
|
+
if (number) idByNumber.set(number, id);
|
|
29
|
+
}
|
|
30
|
+
const childrenOf = new Map<string, string[]>();
|
|
31
|
+
const roots: string[] = [];
|
|
32
|
+
for (const id of memberIds) {
|
|
33
|
+
const number = outline.get(id)?.number;
|
|
34
|
+
const parentNumber = number?.includes('.') ? number.slice(0, number.lastIndexOf('.')) : undefined;
|
|
35
|
+
const parentId = parentNumber ? idByNumber.get(parentNumber) : undefined;
|
|
36
|
+
if (parentId) childrenOf.set(parentId, [...(childrenOf.get(parentId) ?? []), id]);
|
|
37
|
+
else roots.push(id);
|
|
38
|
+
}
|
|
39
|
+
const build = (id: string): PartTreeEntry => ({ id, children: (childrenOf.get(id) ?? []).map(build) });
|
|
40
|
+
return roots.map(build);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Slice D1 item 5: an anatomy pin's number and style, looked up by member id — the pure lookup
|
|
44
|
+
* behind `AnatomyPin` (which reads this through React context so a page's `anatomy.picture` never
|
|
45
|
+
* hardcodes a number). `undefined` for an unknown member, or one with no number (outside any
|
|
46
|
+
* `inWhole` section, or under an unnumbered parent — `numberFor` never assigns those one;
|
|
47
|
+
* `validateFamily`'s D2 item A2 now reports any anatomy item naming such a member, but this stays
|
|
48
|
+
* defensive for a caller outside the anatomy legend/picture, which that rule doesn't cover). A
|
|
49
|
+
* dashed pin marks a `documentation-only` member — spec: "no area of their own". */
|
|
50
|
+
export function anatomyPinFor(
|
|
51
|
+
memberId: string,
|
|
52
|
+
members: ReadonlyMap<string, Pick<CatalogMember, 'fidelity'>>,
|
|
53
|
+
outline: ReadonlyMap<string, { number: string }>,
|
|
54
|
+
): { number: string; dashed: boolean } | undefined {
|
|
55
|
+
const member = members.get(memberId);
|
|
56
|
+
if (!member) return undefined;
|
|
57
|
+
const entry = outline.get(memberId);
|
|
58
|
+
if (!entry) return undefined;
|
|
59
|
+
return { number: entry.number, dashed: member.fidelity === 'documentation-only' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Distributes a legend's groups into up to `maxColumns` columns so the columns end as close to
|
|
63
|
+
* level as possible: each group, in authored order, goes into the column that is currently
|
|
64
|
+
* shortest (the leftmost on a tie). A group's height is its items plus one row for its label. A
|
|
65
|
+
* group never splits across columns, and the groups in each column keep their authored order. */
|
|
66
|
+
export function anatomyColumns<T extends { items: ReadonlyArray<unknown> }>(groups: readonly T[], maxColumns = 3): T[][] {
|
|
67
|
+
if (groups.length === 0) return [];
|
|
68
|
+
const columnCount = Math.min(maxColumns, groups.length);
|
|
69
|
+
const columns: T[][] = Array.from({ length: columnCount }, () => []);
|
|
70
|
+
const heights = new Array<number>(columnCount).fill(0);
|
|
71
|
+
for (const group of groups) {
|
|
72
|
+
let shortest = 0;
|
|
73
|
+
for (let i = 1; i < columnCount; i += 1) if (heights[i] < heights[shortest]) shortest = i;
|
|
74
|
+
columns[shortest].push(group);
|
|
75
|
+
heights[shortest] += group.items.length + 1;
|
|
76
|
+
}
|
|
77
|
+
return columns;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `aria-level` for a member at a given outline level: the page title is 1, so every outline
|
|
81
|
+
* level is shifted down by one, capped at 6 (spec §5.2 "Heading semantics"). The whole (outline
|
|
82
|
+
* level 1) and section labels both land on 2 this way too. */
|
|
83
|
+
export function ariaLevelFor(level: number): number {
|
|
84
|
+
return Math.min(level + 1, 6);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The disclosure's closed-state summary label (spec §5.2). V2b item 9: "Swift API" is Hermex
|
|
88
|
+
* wording and must not be hardcoded in a generic viewer — `apiLabel` is the page's own label
|
|
89
|
+
* (default `'API'`), and `hasProps` is whether the member has resolved props to show (the same
|
|
90
|
+
* condition `disclosureContent` uses for its own "props" part). V2d item 2: `composedOf` ("Uses")
|
|
91
|
+
* counts the same way `notes` already does here — either one alone (or together) gives "Notes"
|
|
92
|
+
* alone, or combines with api/props into "Notes and ${apiLabel}". */
|
|
93
|
+
export function disclosureLabel(member: CatalogMember, apiLabel = 'API', hasProps = false): string {
|
|
94
|
+
const hasNotesOrUses = Boolean(member.notes && member.notes.length > 0) || Boolean(member.composedOf && member.composedOf.length > 0);
|
|
95
|
+
const hasApiOrProps = Boolean(member.api) || hasProps;
|
|
96
|
+
if (hasNotesOrUses && hasApiOrProps) return `Notes and ${apiLabel}`;
|
|
97
|
+
if (hasApiOrProps) return apiLabel;
|
|
98
|
+
if (hasNotesOrUses) return 'Notes';
|
|
99
|
+
return 'Details';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type DisclosurePart = 'notes' | 'uses' | 'whenToUse' | 'a11y' | 'props' | 'api';
|
|
103
|
+
|
|
104
|
+
/** The disclosure's content, in order (spec §5.2 "Disclosure", V2d item 2): notes, then Uses
|
|
105
|
+
* (`composedOf`, non-empty only), then Guidance (`whenToUse`), then Accessibility (`a11y`, unless
|
|
106
|
+
* `hide.accessibility`), then the generated Props table (unless `hide.props`), then the Swift
|
|
107
|
+
* excerpt (`api`). Omits any part whose data is absent. Errata 6: `props` is listed only when the
|
|
108
|
+
* caller actually has resolved props to show — `resolvedProps` is the member's own generated
|
|
109
|
+
* `props` (from its `component`, if any) — never merely because `hide.props` is unset; a
|
|
110
|
+
* documentation-only member with no component has no props to omit from, so it never shows an
|
|
111
|
+
* (empty) Props table. */
|
|
112
|
+
export function disclosureContent(member: CatalogMember, resolvedProps?: readonly PropDef[]): DisclosurePart[] {
|
|
113
|
+
const parts: DisclosurePart[] = [];
|
|
114
|
+
if (member.notes && member.notes.length > 0) parts.push('notes');
|
|
115
|
+
if (member.composedOf && member.composedOf.length > 0) parts.push('uses');
|
|
116
|
+
if (member.whenToUse) parts.push('whenToUse');
|
|
117
|
+
if (member.a11y && !member.hide?.accessibility) parts.push('a11y');
|
|
118
|
+
if (resolvedProps && resolvedProps.length > 0 && !member.hide?.props) parts.push('props');
|
|
119
|
+
if (member.api) parts.push('api');
|
|
120
|
+
return parts;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The section a member id belongs to, or `undefined` for the whole (which is in no section) or an
|
|
124
|
+
* unknown id. Used to decide whether addressing a member should open a folded row (its section's
|
|
125
|
+
* `kind` is `'list'`). */
|
|
126
|
+
export function sectionFor(sections: readonly CatalogMemberSection[], memberId: string): CatalogMemberSection | undefined {
|
|
127
|
+
return sections.find((section) => section.members.includes(memberId));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** N7: the whole member, resolved from `family.whole` against `family.members` — `undefined` for
|
|
131
|
+
* bad data (a `whole` that isn't one of its own members, already reported by `validateFamily`),
|
|
132
|
+
* so `FamilyPage` can skip rendering the whole block instead of crashing on an absent member.
|
|
133
|
+
* Generic over the member's own shape so `FamilyPage` keeps its resolved `props`/`path` fields
|
|
134
|
+
* (`FamilyDef.members`'s own type) through the lookup. */
|
|
135
|
+
export function resolveWhole<T>(members: ReadonlyMap<string, T>, whole: string): T | undefined {
|
|
136
|
+
return members.get(whole);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** V2c item 1 (one-shot jump) + item 11 (repeat activation): in-flight state for a member-address
|
|
140
|
+
* scroll/focus jump, reset fresh on every new activation (`FamilyPage`'s own effect, keyed by a
|
|
141
|
+
* nonce from `CatalogShell` so a repeat activation of the SAME member still counts as new).
|
|
142
|
+
* `passesLeft` starts at 2 for a folded (`list`) row — the row's first, still-closed layout, then
|
|
143
|
+
* its second, now-open one (V2b item 8) — or 1 for a block member, which has only one layout to
|
|
144
|
+
* scroll against. */
|
|
145
|
+
export interface MemberJumpState {
|
|
146
|
+
memberId: string;
|
|
147
|
+
passesLeft: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Whether a member-address scroll/focus pass should run right now: only while `state` still
|
|
151
|
+
* belongs to the member currently addressed and still has a pass left. Once every pass for the
|
|
152
|
+
* current activation has run, toggling an unrelated (or even the same) row afterwards — which
|
|
153
|
+
* changes nothing this state tracks — must never make this true again. */
|
|
154
|
+
export function shouldRunMemberJump(state: MemberJumpState | undefined, memberId: string): boolean {
|
|
155
|
+
return state !== undefined && state.memberId === memberId && state.passesLeft > 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One segment of a `related` sentence (spec §5.2): plain text, or a member id that should render
|
|
159
|
+
* as a link to that member's address (`RelatedSegment.memberId` is also that member's display
|
|
160
|
+
* name — see `CatalogMember.id`'s own doc comment). */
|
|
161
|
+
export interface RelatedSegment {
|
|
162
|
+
text: string;
|
|
163
|
+
memberId?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Escapes a string for literal use inside a `RegExp` (the characters the regex engine would
|
|
167
|
+
* otherwise treat as metacharacters). A Swift declaration name never contains any of these, but
|
|
168
|
+
* this keeps `linkifyRelated` correct even if one ever did. */
|
|
169
|
+
function escapeRegExp(value: string): string {
|
|
170
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Splits a `related` sentence (spec §5.1 `CatalogMember.related`) into segments, turning every
|
|
174
|
+
* whole-word occurrence of another member's id into a linked segment; every other run of text is
|
|
175
|
+
* a plain segment. Matches the longest id first, so a shorter id that is a prefix of a longer one
|
|
176
|
+
* (e.g. "BotAvatarView" inside "BotAvatarViewLegacy") never splits the longer one apart. Returns
|
|
177
|
+
* the whole text as one plain segment when there are no ids, or no match. */
|
|
178
|
+
export function linkifyRelated(text: string, memberIds: readonly string[]): RelatedSegment[] {
|
|
179
|
+
if (memberIds.length === 0) return [{ text }];
|
|
180
|
+
const ids = [...memberIds].sort((a, b) => b.length - a.length);
|
|
181
|
+
const pattern = new RegExp(`\\b(?:${ids.map(escapeRegExp).join('|')})\\b`, 'g');
|
|
182
|
+
const segments: RelatedSegment[] = [];
|
|
183
|
+
let lastIndex = 0;
|
|
184
|
+
let match: RegExpExecArray | null;
|
|
185
|
+
while ((match = pattern.exec(text))) {
|
|
186
|
+
if (match.index > lastIndex) segments.push({ text: text.slice(lastIndex, match.index) });
|
|
187
|
+
segments.push({ text: match[0], memberId: match[0] });
|
|
188
|
+
lastIndex = match.index + match[0].length;
|
|
189
|
+
}
|
|
190
|
+
if (lastIndex < text.length) segments.push({ text: text.slice(lastIndex) });
|
|
191
|
+
return segments.length > 0 ? segments : [{ text }];
|
|
192
|
+
}
|
package/native/catalog/index.ts
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
export { CatalogShell } from './CatalogShell';
|
|
16
16
|
export { CatalogSidebar } from './CatalogSidebar';
|
|
17
17
|
export { CatalogSearchInput } from './CatalogSearchInput';
|
|
18
|
-
export { SectionBlock } from './SectionBlock';
|
|
18
|
+
export { SectionBlock, PagerButton, SectionPagerRow } from './SectionBlock';
|
|
19
|
+
export type { SectionPager } from './SectionBlock';
|
|
20
|
+
export { FamilyPage, AnatomyPin } from './FamilyPage';
|
|
19
21
|
export { ComparisonGrid } from './ComparisonGrid';
|
|
20
22
|
export { grid, axisItems, axisProp } from './comparison';
|
|
21
23
|
export { ComparisonGroups } from './ComparisonGroups';
|
|
@@ -46,7 +48,8 @@ export { defineCatalogPage, buildCatalogSections } from './pageApi';
|
|
|
46
48
|
export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRecord } from './pageApi';
|
|
47
49
|
export type {
|
|
48
50
|
PropDef, SectionDef, NavGroup, SpecimenSize, SpecimenSurfaceKind, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis,
|
|
49
|
-
PreviewWidths, TokenSection, ComposedOfEntry,
|
|
51
|
+
PreviewWidths, TokenSection, ComposedOfEntry, CatalogMember, CatalogMemberSection, FamilyDef,
|
|
52
|
+
CatalogAnatomy, CatalogAnatomyGroup, CatalogAnatomyItem,
|
|
50
53
|
} from './types';
|
|
51
54
|
export { UpdatePanel } from './UpdatePanel';
|
|
52
55
|
export { UPDATE_PAGE_ID } from './catalogNavigation';
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* render) resolves an address back to the `node` it names.
|
|
7
7
|
*/
|
|
8
8
|
import type React from 'react';
|
|
9
|
-
import type { SectionDef } from './types.ts';
|
|
9
|
+
import type { ComparisonDef, SectionDef, VariantSlot } from './types.ts';
|
|
10
10
|
|
|
11
11
|
/** Which block on a page an addressed specimen's `node` lives in — see `findOverlayNode`. */
|
|
12
12
|
export type OverlaySlot = 'variants' | 'states' | 'comparison' | 'preview';
|
|
@@ -109,19 +109,39 @@ export function resolveOverlayViewport(args: {
|
|
|
109
109
|
return { mode: 'frame', href: `${args.pathname}${overlayViewportHrefSuffix(args.address)}` };
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/** The slots `findOverlayNode` reads — shared by a top-level `SectionDef` and a family member
|
|
113
|
+
* (`CatalogMember`), which carry the exact same `variants`/`states`/`comparison`/`render` shape. */
|
|
114
|
+
interface SpecimenSource {
|
|
115
|
+
variants?: VariantSlot;
|
|
116
|
+
states?: VariantSlot;
|
|
117
|
+
comparison?: ComparisonDef;
|
|
118
|
+
render?: () => React.ReactNode;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readSpecimenSlot(source: SpecimenSource, address: OverlayAddress): React.ReactNode | undefined {
|
|
122
|
+
if (address.slot === 'variants') return source.variants?.items.find((item) => item.key === address.itemKey)?.node;
|
|
123
|
+
if (address.slot === 'states') return source.states?.items.find((item) => item.key === address.itemKey)?.node;
|
|
124
|
+
// A `render()` page's preview (one per page; `itemKey` is always 'preview').
|
|
125
|
+
if (address.slot === 'preview') return source.render?.();
|
|
126
|
+
// Matched by recomputing each cell's own canonical key, never by splitting `address.itemKey` —
|
|
127
|
+
// a row/column key may itself contain any delimiter this module could pick.
|
|
128
|
+
return source.comparison?.cells.find((cell) => comparisonCellItemKey(cell.rowKey, cell.columnKey) === address.itemKey)?.node;
|
|
129
|
+
}
|
|
130
|
+
|
|
112
131
|
/** The authored `node` an `OverlayAddress` points at, or `undefined` for a stale/unknown address
|
|
113
132
|
* (a removed example, a renamed key, a typo) — `CatalogShell`'s chromeless selected-document
|
|
114
|
-
* render shows an honest "unavailable" recovery rather than rendering nothing silently.
|
|
133
|
+
* render shows an honest "unavailable" recovery rather than rendering nothing silently.
|
|
134
|
+
* V2c item 3: a specimen built inside a family member (`SectionBlock.tsx`'s `memberAsSectionDef`)
|
|
135
|
+
* addresses itself with the member's own id as `pageId` — that never matches a top-level
|
|
136
|
+
* `SectionDef.id`, so this also searches every family page's own `family.members` for it. */
|
|
115
137
|
export function findOverlayNode(sections: readonly SectionDef<string>[], address: OverlayAddress): React.ReactNode | undefined {
|
|
116
138
|
const def = sections.find((section) => section.id === address.pageId);
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
// a row/column key may itself contain any delimiter this module could pick.
|
|
124
|
-
return def.comparison?.cells.find((cell) => comparisonCellItemKey(cell.rowKey, cell.columnKey) === address.itemKey)?.node;
|
|
139
|
+
if (def) return readSpecimenSlot(def, address);
|
|
140
|
+
for (const section of sections) {
|
|
141
|
+
const member = section.family?.members.find((m) => m.id === address.pageId);
|
|
142
|
+
if (member) return readSpecimenSlot(member, address);
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
125
145
|
}
|
|
126
146
|
|
|
127
147
|
/** The canonical, unambiguous `itemKey` for one comparison cell's row+column — this module's own
|