@krapwoo/ds-viewer 0.13.1 → 0.14.1
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 +18 -2
- package/dist/native/catalog/catalogNavigation.js +27 -10
- package/dist/native/catalog/pageApi.js +62 -0
- package/native/catalog/CatalogShell.tsx +1 -1
- package/native/catalog/FamilyPage.tsx +69 -10
- package/native/catalog/catalogNavigation.ts +26 -8
- package/native/catalog/familyModel.ts +32 -1
- package/native/catalog/index.ts +1 -1
- package/native/catalog/pageApi.ts +57 -1
- package/native/catalog/types.ts +18 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -427,7 +427,8 @@ path. For anything beyond a single list of tokens, use the token-page layouts be
|
|
|
427
427
|
alphabetically after the ones that are. The starter kit uses `Tokens`, then every
|
|
428
428
|
component under `Components`, then `Patterns` (whole screens built from the kit, such as
|
|
429
429
|
SavedTrips) and `Reference` (the generated manifest). A larger catalog might add `Recipes` or
|
|
430
|
-
`Experiences` (full flows) alongside them. Pages sort A–Z
|
|
430
|
+
`Experiences` (full flows) alongside them. Pages sort A–Z by their displayed title (falling back
|
|
431
|
+
to their id when they have none; an exact tie breaks on id) inside each group, regardless of how
|
|
431
432
|
many groups you have. Avoid splitting `Components` into finer categories such as "Actions" or
|
|
432
433
|
"Status" purely to browse by — that makes readers guess where a component lives; search and A–Z
|
|
433
434
|
already find it. A distinct semantic group (Tokens vs. Components vs. Patterns) is a different
|
|
@@ -444,7 +445,8 @@ path. For anything beyond a single list of tokens, use the token-page layouts be
|
|
|
444
445
|
usually can't render. Custom replacements styled after a platform control (a custom switch or
|
|
445
446
|
spinner) are not OS components.
|
|
446
447
|
- **Readable titles.** The page id is its address (`#ControlHeights`) and comes from the file
|
|
447
|
-
name; set `title: 'Control heights'` for the heading, sidebar and search. Pages
|
|
448
|
+
name; set `title: 'Control heights'` for the heading, sidebar and search. Pages now sort by this
|
|
449
|
+
title, not the id — so "Control heights" sorts under "C", not under whatever the id starts with.
|
|
448
450
|
- **Point token pages at their token file.** A token page has no component, so its Source would be
|
|
449
451
|
the page file; set `source: 'src/design-system/tokens.ts'` (project-relative) instead.
|
|
450
452
|
- **Coverage only counts the component's own options.** `doctor`'s `option-not-covered` checks
|
|
@@ -537,6 +539,20 @@ export default defineCatalogPage({
|
|
|
537
539
|
resolves to a real catalog address (a page, another member, or an alias — in this family or any
|
|
538
540
|
other), otherwise it shows in code; an unknown name logs a `[Catalog]` warning the same way the
|
|
539
541
|
page-level field's does.
|
|
542
|
+
- **"Also used by …"** is derived, never authored — there is no field for it anywhere. It's the
|
|
543
|
+
inverse of the "Uses …" line above: if some other page or member's own `composedOf` names this
|
|
544
|
+
one with `relationship: 'built-in'`, that's already a declaration that the other one uses it;
|
|
545
|
+
`buildCatalogSections` inverts every such declaration across the whole catalog and shows the
|
|
546
|
+
result as a short line below the member's specimens (joining the same line as its source path),
|
|
547
|
+
so the same relationship never has to be authored a second time from this end. Only `'built-in'`
|
|
548
|
+
counts — `'slot'` and `'related'` never produce a line. A user that is this member's own parent,
|
|
549
|
+
the page's own whole, the member itself, or any other member of the same family page is excluded:
|
|
550
|
+
that edge is the family tree already, not a second user. One user shows as `Also used by X
|
|
551
|
+
(role)` (role omitted when empty); two or three as `Also used by X, Y and Z`, with no roles —
|
|
552
|
+
they bloat the line; more than three as `Also used by X, Y and Z +N more`, with `+N more` as
|
|
553
|
+
plain text. A user that resolves to a real catalog address links the same way a
|
|
554
|
+
`related`/`composedOf` name does; otherwise it shows as plain text. Nothing renders when nothing
|
|
555
|
+
else uses the member — the overwhelming majority of them.
|
|
540
556
|
- **`notShown`** (page-level) is a short "Not shown in the browser: …" line under the page head,
|
|
541
557
|
for behavior a web catalog can't demonstrate (a timer, a native gesture). A member's own
|
|
542
558
|
`notShown` works the same way, under its specimens. Like `aliases`, the page-level field only
|
|
@@ -1,15 +1,32 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
}
|
|
5
|
-
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
1
|
+
/** Fixed English locale, case-insensitive, accent-distinguishing — the one comparator every
|
|
2
|
+
* canonical ordering (sidebar, filtered matches, previous/next, hash fallback) shares, so none of
|
|
3
|
+
* them can drift against each other. */
|
|
4
|
+
const TITLE_COLLATOR = new Intl.Collator('en', { sensitivity: 'accent' });
|
|
5
|
+
/** THE canonical ordering of two ids: by displayed title (`labels.get(id) ?? id`) first, then —
|
|
6
|
+
* for an equal title — by id with the same collator, then by exact code-unit id comparison for
|
|
7
|
+
* the rare case where case-insensitive ids also compare equal. */
|
|
8
|
+
function compareCanonical(a, b, labels) {
|
|
9
|
+
const byTitle = TITLE_COLLATOR.compare(labels?.get(a) ?? a, labels?.get(b) ?? b);
|
|
10
|
+
if (byTitle !== 0)
|
|
11
|
+
return byTitle;
|
|
12
|
+
const byId = TITLE_COLLATOR.compare(a, b);
|
|
13
|
+
if (byId !== 0)
|
|
14
|
+
return byId;
|
|
15
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
16
|
+
}
|
|
17
|
+
/** THE canonical within-group ordering of section ids: by each id's displayed title
|
|
18
|
+
* (`SectionDef.title`, via `labels`), falling back to the id itself when a page has no title. */
|
|
19
|
+
export function sortIds(ids, labels) {
|
|
20
|
+
return ids.slice().sort((a, b) => compareCanonical(a, b, labels));
|
|
21
|
+
}
|
|
22
|
+
/** Every page id in sidebar order: groups in declared order, ids sorted within each group (by
|
|
23
|
+
* displayed title, via `labels`), first occurrence wins, and ids without a matching section (when
|
|
24
|
+
* `availableIds` is given) are dropped. */
|
|
25
|
+
export function orderedIds(groups, availableIds, labels) {
|
|
9
26
|
const seen = new Set();
|
|
10
27
|
const order = [];
|
|
11
28
|
for (const group of groups) {
|
|
12
|
-
for (const id of sortIds(group.ids)) {
|
|
29
|
+
for (const id of sortIds(group.ids, labels)) {
|
|
13
30
|
if (seen.has(id))
|
|
14
31
|
continue;
|
|
15
32
|
if (availableIds && !availableIds.has(id))
|
|
@@ -46,7 +63,7 @@ memberIds) {
|
|
|
46
63
|
label: group.label,
|
|
47
64
|
ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q)
|
|
48
65
|
|| (labels?.get(id)?.toLowerCase().includes(q) ?? false)
|
|
49
|
-
|| (memberIds?.get(id)?.some((memberId) => memberId.toLowerCase().includes(q)) ?? false)) : group.ids),
|
|
66
|
+
|| (memberIds?.get(id)?.some((memberId) => memberId.toLowerCase().includes(q)) ?? false)) : group.ids, labels),
|
|
50
67
|
}))
|
|
51
68
|
.filter((group) => group.ids.length > 0);
|
|
52
69
|
}
|
|
@@ -309,6 +309,48 @@ pages, components, groupOrder) {
|
|
|
309
309
|
...pages.flatMap((page) => (page.members ?? []).flatMap((member) => member.aliases ?? [])),
|
|
310
310
|
...pages.flatMap((page) => page.aliases ?? []),
|
|
311
311
|
]);
|
|
312
|
+
// A member only ever looks itself up (below) by its own canonical `id`, but `knownComposedOfNames`
|
|
313
|
+
// (N8, above) deliberately lets a `composedOf` entry's own `component` name a member's *alias*, or
|
|
314
|
+
// the member's `component` field instead of its `id` — both resolve as working links through
|
|
315
|
+
// `FamilyPage`'s own `resolvesAddress`. Without resolving through the same map here, an
|
|
316
|
+
// alias-named or component-named entry would link fine going forward but derive nothing going
|
|
317
|
+
// back: a missing "Also used by" line looks identical to "nothing uses this".
|
|
318
|
+
const canonicalMemberIdByAlias = new Map();
|
|
319
|
+
for (const page of pages) {
|
|
320
|
+
for (const member of page.members ?? []) {
|
|
321
|
+
for (const alias of member.aliases ?? [])
|
|
322
|
+
canonicalMemberIdByAlias.set(alias, member.id);
|
|
323
|
+
if (member.component && member.component !== member.id)
|
|
324
|
+
canonicalMemberIdByAlias.set(member.component, member.id);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const canonicalUsedByTarget = (name) => canonicalMemberIdByAlias.get(name) ?? name;
|
|
328
|
+
// "Also used by" (derived, never authored): the inverse of every `composedOf` entry in the
|
|
329
|
+
// catalog whose `relationship` is `'built-in'` — the forward direction renders as "Uses X"
|
|
330
|
+
// (`UsesLine`); this is that same relationship read from the other end, keyed by the entry's own
|
|
331
|
+
// `component` (the used thing) — gathered once, up front, so a family member just looks up its
|
|
332
|
+
// own id below. A page's own `composedOf` attributes to that page's id (its address, e.g. a
|
|
333
|
+
// family's whole); a family member's own `composedOf` attributes to that member's own id.
|
|
334
|
+
const usedByTarget = new Map();
|
|
335
|
+
const addUsedBy = (target, component, role) => {
|
|
336
|
+
const list = usedByTarget.get(target);
|
|
337
|
+
if (list)
|
|
338
|
+
list.push({ component, role });
|
|
339
|
+
else
|
|
340
|
+
usedByTarget.set(target, [{ component, role }]);
|
|
341
|
+
};
|
|
342
|
+
for (const page of pages) {
|
|
343
|
+
for (const entry of page.composedOf ?? []) {
|
|
344
|
+
if (entry.relationship === 'built-in')
|
|
345
|
+
addUsedBy(canonicalUsedByTarget(entry.component), page.id, entry.role);
|
|
346
|
+
}
|
|
347
|
+
for (const member of page.members ?? []) {
|
|
348
|
+
for (const entry of member.composedOf ?? []) {
|
|
349
|
+
if (entry.relationship === 'built-in')
|
|
350
|
+
addUsedBy(canonicalUsedByTarget(entry.component), member.id, entry.role);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
312
354
|
// Every id/alias a family page's own `members` and `aliases` use, keyed by page id — gathered up
|
|
313
355
|
// front so `validateFamily` (above) can check catalog-wide uniqueness against every *other*
|
|
314
356
|
// page's ids.
|
|
@@ -375,6 +417,10 @@ pages, components, groupOrder) {
|
|
|
375
417
|
for (const issue of issues)
|
|
376
418
|
console.error(`[Catalog] ${issue}`);
|
|
377
419
|
}
|
|
420
|
+
// "Also used by": every id this page's own members (the whole included) use is excluded
|
|
421
|
+
// from its own members' derived users — a sibling (or the whole) composing a part is still
|
|
422
|
+
// the same family tree, already expressed by `parent`/the page itself, not a second user.
|
|
423
|
+
const pageMemberIds = new Set(page.members.map((m) => m.id));
|
|
378
424
|
family = {
|
|
379
425
|
whole: page.whole,
|
|
380
426
|
sections: page.sections,
|
|
@@ -386,10 +432,26 @@ pages, components, groupOrder) {
|
|
|
386
432
|
relatedPages: page.relatedPages,
|
|
387
433
|
members: page.members.map((member) => {
|
|
388
434
|
const memberComponent = member.component ? componentByName.get(member.component) : undefined;
|
|
435
|
+
// Dedupe by `component`, keeping the first role: page-level and whole-member `composedOf`
|
|
436
|
+
// attribute to the same string for a family's whole (`validateFamily` forces
|
|
437
|
+
// `whole === page.id`), and `validateComposedOf` has no duplicate check of its own, so the
|
|
438
|
+
// same user declared twice would otherwise render "Also used by P and P".
|
|
439
|
+
const dedupedUsers = new Map();
|
|
440
|
+
for (const entry of usedByTarget.get(member.id) ?? []) {
|
|
441
|
+
if (pageMemberIds.has(entry.component))
|
|
442
|
+
continue;
|
|
443
|
+
if (!dedupedUsers.has(entry.component))
|
|
444
|
+
dedupedUsers.set(entry.component, entry);
|
|
445
|
+
}
|
|
446
|
+
const alsoUsedBy = [...dedupedUsers.values()].map((entry) => ({
|
|
447
|
+
component: entry.component,
|
|
448
|
+
role: entry.role || undefined,
|
|
449
|
+
}));
|
|
389
450
|
return {
|
|
390
451
|
...member,
|
|
391
452
|
props: memberComponent ? mergePropNotes(memberComponent, undefined) : undefined,
|
|
392
453
|
path: memberComponent?.file ?? member.source,
|
|
454
|
+
alsoUsedBy,
|
|
393
455
|
};
|
|
394
456
|
}),
|
|
395
457
|
};
|
|
@@ -221,7 +221,7 @@ export function CatalogShell<TId extends string>({
|
|
|
221
221
|
|
|
222
222
|
const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
|
|
223
223
|
const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
|
|
224
|
-
const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys())), [groups, sectionsById]);
|
|
224
|
+
const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys()), labels), [groups, sectionsById, labels]);
|
|
225
225
|
// Spec §5.3: every family page's own member ids/aliases, resolved to their page (and member, for
|
|
226
226
|
// anything other than the whole) — used both for an old/alias/member address (`resolveRoute`)
|
|
227
227
|
// and for the sidebar's member search (`memberSearchEntries` below).
|
|
@@ -11,6 +11,7 @@ import { hashForId } from './catalogNavigation';
|
|
|
11
11
|
// component with no test of its own that imports it directly under Node, so it only ever goes
|
|
12
12
|
// through the bundler.
|
|
13
13
|
import {
|
|
14
|
+
alsoUsedBySegments,
|
|
14
15
|
anatomyColumns,
|
|
15
16
|
anatomyPinFor,
|
|
16
17
|
ariaLevelFor,
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
type PartTreeEntry,
|
|
27
28
|
type RelatedSegment,
|
|
28
29
|
} from './familyModel';
|
|
29
|
-
import type { CatalogAnatomy, CatalogAnatomyGroup, CatalogMember, CatalogMemberSection, ComposedOfEntry, FamilyDef, PreviewWidths, PropDef, RelatedPage, SectionDef } from './types';
|
|
30
|
+
import type { AlsoUsedByEntry, CatalogAnatomy, CatalogAnatomyGroup, CatalogMember, CatalogMemberSection, ComposedOfEntry, FamilyDef, PreviewWidths, PropDef, RelatedPage, SectionDef } from './types';
|
|
30
31
|
|
|
31
32
|
/** `SectionDef`'s parts `presentationBlocks`/`Blocks` actually read, built from one family member
|
|
32
33
|
* so its own specimens render through the exact same renderers (`VariantGroup`, `ComparisonGrid`,
|
|
@@ -125,21 +126,28 @@ function MemberNameRow({
|
|
|
125
126
|
);
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
/** `[related ·] [relationship ·] source
|
|
129
|
-
*
|
|
129
|
+
/** `[related ·] [relationship ·] source [· Also used by …]` (spec §5.2 "Below the specimens",
|
|
130
|
+
* derived "also used by" slice). A member name inside `related`, and a resolvable component in
|
|
131
|
+
* the derived "Also used by" segment, both link to that address — a real `href`, like every other
|
|
130
132
|
* in-app link, so clicking it goes through the same `hashchange` routing as typing the address or
|
|
131
|
-
* using search (`CatalogShell`).
|
|
133
|
+
* using search (`CatalogShell`). An unresolvable user (a declared user with no catalog page of its
|
|
134
|
+
* own) renders as plain text instead, the same way `UsesLine` treats an unresolvable name. */
|
|
132
135
|
function BelowSpecimensLine({
|
|
133
136
|
related,
|
|
134
137
|
showRelationship,
|
|
135
138
|
relationship,
|
|
136
139
|
source,
|
|
140
|
+
alsoUsedBy,
|
|
141
|
+
resolvesAddress,
|
|
137
142
|
}: {
|
|
138
143
|
related?: RelatedSegment[];
|
|
139
144
|
showRelationship: boolean;
|
|
140
145
|
relationship: string;
|
|
141
146
|
source: string;
|
|
147
|
+
alsoUsedBy: AlsoUsedByEntry[];
|
|
148
|
+
resolvesAddress: (name: string) => boolean;
|
|
142
149
|
}) {
|
|
150
|
+
const usedBy = alsoUsedBySegments(alsoUsedBy);
|
|
143
151
|
return (
|
|
144
152
|
<Text style={styles.belowLine}>
|
|
145
153
|
{related &&
|
|
@@ -155,13 +163,37 @@ function BelowSpecimensLine({
|
|
|
155
163
|
{related && ' · '}
|
|
156
164
|
{showRelationship && `${relationship} · `}
|
|
157
165
|
{source}
|
|
166
|
+
{usedBy.length > 0 && ' · '}
|
|
167
|
+
{usedBy.map((segment, i) =>
|
|
168
|
+
segment.component && resolvesAddress(segment.component) ? (
|
|
169
|
+
<Text key={i} role="link" style={styles.relatedLink} {...({ href: hashForId(segment.component) } as Record<string, unknown>)}>
|
|
170
|
+
{segment.text}
|
|
171
|
+
</Text>
|
|
172
|
+
) : (
|
|
173
|
+
<Text key={i}>{segment.text}</Text>
|
|
174
|
+
),
|
|
175
|
+
)}
|
|
158
176
|
</Text>
|
|
159
177
|
);
|
|
160
178
|
}
|
|
161
179
|
|
|
162
|
-
/** Slice D1 item 6: the whole's own one-line summary — `<name> · <fidelity badge> · <source
|
|
163
|
-
* place of the below-specimens line (the whole has no
|
|
164
|
-
|
|
180
|
+
/** Slice D1 item 6: the whole's own one-line summary — `<name> · <fidelity badge> · <source>
|
|
181
|
+
* [· Also used by …]` — in place of the below-specimens line (the whole has no
|
|
182
|
+
* `relationship`/`related` to show, but it is still a member, so it still derives users). */
|
|
183
|
+
function WholeBelowLine({
|
|
184
|
+
name,
|
|
185
|
+
fidelity,
|
|
186
|
+
source,
|
|
187
|
+
alsoUsedBy,
|
|
188
|
+
resolvesAddress,
|
|
189
|
+
}: {
|
|
190
|
+
name: string;
|
|
191
|
+
fidelity: CatalogMember['fidelity'];
|
|
192
|
+
source: string;
|
|
193
|
+
alsoUsedBy: AlsoUsedByEntry[];
|
|
194
|
+
resolvesAddress: (name: string) => boolean;
|
|
195
|
+
}) {
|
|
196
|
+
const usedBy = alsoUsedBySegments(alsoUsedBy);
|
|
165
197
|
return (
|
|
166
198
|
<View style={styles.wholeBelowRow}>
|
|
167
199
|
<Text style={styles.wholeName}>{name}</Text>
|
|
@@ -169,6 +201,18 @@ function WholeBelowLine({ name, fidelity, source }: { name: string; fidelity: Ca
|
|
|
169
201
|
<FidelityBadge fidelity={fidelity} />
|
|
170
202
|
<Text style={styles.belowLine}> · </Text>
|
|
171
203
|
<Text style={styles.belowLine}>{source}</Text>
|
|
204
|
+
{usedBy.length > 0 && <Text style={styles.belowLine}> · </Text>}
|
|
205
|
+
{usedBy.map((segment, i) =>
|
|
206
|
+
segment.component && resolvesAddress(segment.component) ? (
|
|
207
|
+
<Text key={i} role="link" style={styles.relatedLink} {...({ href: hashForId(segment.component) } as Record<string, unknown>)}>
|
|
208
|
+
{segment.text}
|
|
209
|
+
</Text>
|
|
210
|
+
) : (
|
|
211
|
+
<Text key={i} style={styles.belowLine}>
|
|
212
|
+
{segment.text}
|
|
213
|
+
</Text>
|
|
214
|
+
),
|
|
215
|
+
)}
|
|
172
216
|
</View>
|
|
173
217
|
);
|
|
174
218
|
}
|
|
@@ -294,7 +338,7 @@ function MemberDisclosureToggle({
|
|
|
294
338
|
);
|
|
295
339
|
}
|
|
296
340
|
|
|
297
|
-
type FamilyMember = CatalogMember & { props?: PropDef[]; path: string };
|
|
341
|
+
type FamilyMember = CatalogMember & { props?: PropDef[]; path: string; alsoUsedBy?: AlsoUsedByEntry[] };
|
|
298
342
|
|
|
299
343
|
interface MemberRenderProps {
|
|
300
344
|
member: FamilyMember;
|
|
@@ -377,6 +421,8 @@ function FamilyPart({ member, number, level, allMemberIds, defaultPreviewWidths,
|
|
|
377
421
|
showRelationship={false}
|
|
378
422
|
relationship={member.relationship}
|
|
379
423
|
source={member.source}
|
|
424
|
+
alsoUsedBy={member.alsoUsedBy ?? []}
|
|
425
|
+
resolvesAddress={resolvesAddress}
|
|
380
426
|
/>
|
|
381
427
|
<View style={styles.disclosureSpacer}>
|
|
382
428
|
<MemberDisclosureToggle member={member} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
|
|
@@ -421,7 +467,14 @@ function FoldedRow({ member, number, allMemberIds, resolvesAddress, open, onTogg
|
|
|
421
467
|
web must never bubble its own click into this row's own press handler and close it. */}
|
|
422
468
|
{open && (
|
|
423
469
|
<View style={styles.foldedRowBody}>
|
|
424
|
-
<BelowSpecimensLine
|
|
470
|
+
<BelowSpecimensLine
|
|
471
|
+
related={related}
|
|
472
|
+
showRelationship={false}
|
|
473
|
+
relationship={member.relationship}
|
|
474
|
+
source={member.source}
|
|
475
|
+
alsoUsedBy={member.alsoUsedBy ?? []}
|
|
476
|
+
resolvesAddress={resolvesAddress}
|
|
477
|
+
/>
|
|
425
478
|
{parts.length > 0 && (
|
|
426
479
|
<DisclosureBody member={member} parts={parts} resolvedProps={member.props} resolvesAddress={resolvesAddress} />
|
|
427
480
|
)}
|
|
@@ -1055,7 +1108,13 @@ export function FamilyPage<TId extends string>({
|
|
|
1055
1108
|
</View>
|
|
1056
1109
|
)}
|
|
1057
1110
|
{wholeMember.notShown && <Text style={styles.belowLine}>{wholeMember.notShown}</Text>}
|
|
1058
|
-
<WholeBelowLine
|
|
1111
|
+
<WholeBelowLine
|
|
1112
|
+
name={wholeMember.id}
|
|
1113
|
+
fidelity={wholeMember.fidelity}
|
|
1114
|
+
source={wholeMember.source}
|
|
1115
|
+
alsoUsedBy={wholeMember.alsoUsedBy ?? []}
|
|
1116
|
+
resolvesAddress={resolvesAddress}
|
|
1117
|
+
/>
|
|
1059
1118
|
<View style={styles.disclosureSpacer}>
|
|
1060
1119
|
<MemberDisclosureToggle member={wholeMember} apiLabel={apiLabel} resolvesAddress={resolvesAddress} />
|
|
1061
1120
|
</View>
|
|
@@ -5,22 +5,40 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { NavGroup, SectionDef, UpdateNotice, VersionStatus } from './types.ts';
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
/** Fixed English locale, case-insensitive, accent-distinguishing — the one comparator every
|
|
9
|
+
* canonical ordering (sidebar, filtered matches, previous/next, hash fallback) shares, so none of
|
|
10
|
+
* them can drift against each other. */
|
|
11
|
+
const TITLE_COLLATOR = new Intl.Collator('en', { sensitivity: 'accent' });
|
|
12
|
+
|
|
13
|
+
/** THE canonical ordering of two ids: by displayed title (`labels.get(id) ?? id`) first, then —
|
|
14
|
+
* for an equal title — by id with the same collator, then by exact code-unit id comparison for
|
|
15
|
+
* the rare case where case-insensitive ids also compare equal. */
|
|
16
|
+
function compareCanonical<TId extends string>(a: TId, b: TId, labels?: ReadonlyMap<string, string>): number {
|
|
17
|
+
const byTitle = TITLE_COLLATOR.compare(labels?.get(a) ?? a, labels?.get(b) ?? b);
|
|
18
|
+
if (byTitle !== 0) return byTitle;
|
|
19
|
+
const byId = TITLE_COLLATOR.compare(a, b);
|
|
20
|
+
if (byId !== 0) return byId;
|
|
21
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** THE canonical within-group ordering of section ids: by each id's displayed title
|
|
25
|
+
* (`SectionDef.title`, via `labels`), falling back to the id itself when a page has no title. */
|
|
26
|
+
export function sortIds<TId extends string>(ids: readonly TId[], labels?: ReadonlyMap<string, string>): TId[] {
|
|
27
|
+
return ids.slice().sort((a, b) => compareCanonical(a, b, labels));
|
|
11
28
|
}
|
|
12
29
|
|
|
13
|
-
/** Every page id in sidebar order: groups in declared order, ids sorted within each group
|
|
14
|
-
* first occurrence wins, and ids without a matching section (when
|
|
15
|
-
* dropped. */
|
|
30
|
+
/** Every page id in sidebar order: groups in declared order, ids sorted within each group (by
|
|
31
|
+
* displayed title, via `labels`), first occurrence wins, and ids without a matching section (when
|
|
32
|
+
* `availableIds` is given) are dropped. */
|
|
16
33
|
export function orderedIds<TId extends string>(
|
|
17
34
|
groups: readonly NavGroup<TId>[],
|
|
18
35
|
availableIds?: ReadonlySet<TId>,
|
|
36
|
+
labels?: ReadonlyMap<string, string>,
|
|
19
37
|
): TId[] {
|
|
20
38
|
const seen = new Set<TId>();
|
|
21
39
|
const order: TId[] = [];
|
|
22
40
|
for (const group of groups) {
|
|
23
|
-
for (const id of sortIds(group.ids)) {
|
|
41
|
+
for (const id of sortIds(group.ids, labels)) {
|
|
24
42
|
if (seen.has(id)) continue;
|
|
25
43
|
if (availableIds && !availableIds.has(id)) continue;
|
|
26
44
|
seen.add(id);
|
|
@@ -65,7 +83,7 @@ export function filterGroups<TId extends string>(
|
|
|
65
83
|
id.toLowerCase().includes(q)
|
|
66
84
|
|| (labels?.get(id)?.toLowerCase().includes(q) ?? false)
|
|
67
85
|
|| (memberIds?.get(id)?.some((memberId) => memberId.toLowerCase().includes(q)) ?? false),
|
|
68
|
-
) : group.ids),
|
|
86
|
+
) : group.ids, labels),
|
|
69
87
|
}))
|
|
70
88
|
.filter((group) => group.ids.length > 0);
|
|
71
89
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* building, disclosure content, and link/pin lookups. Outline numbering (`numberFor`) and family
|
|
5
5
|
* validation (`validateFamily`) live in `pageApi.ts` instead — see that file's own comments.
|
|
6
6
|
*/
|
|
7
|
-
import type { CatalogMember, CatalogMemberSection, PropDef } from './types.ts';
|
|
7
|
+
import type { AlsoUsedByEntry, CatalogMember, CatalogMemberSection, PropDef } from './types.ts';
|
|
8
8
|
|
|
9
9
|
/** One node of a `blocks` section's own Part tree (Slice D1 item 7 "Tree line"). */
|
|
10
10
|
export interface PartTreeEntry {
|
|
@@ -163,6 +163,37 @@ export interface RelatedSegment {
|
|
|
163
163
|
memberId?: string;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/** One segment of an "Also used by" line: plain text, or another component's own id, rendered as a
|
|
167
|
+
* link when it resolves to a real catalog address and plain text otherwise (`FamilyPage`'s own
|
|
168
|
+
* `resolvesAddress`, the same test `UsesLine` already uses). */
|
|
169
|
+
export interface UsedBySegment {
|
|
170
|
+
text: string;
|
|
171
|
+
component?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Builds the "Also used by …" segments (field spec "Rendering") from a member's derived
|
|
175
|
+
* `alsoUsedBy` list: `[]` for none (the caller renders nothing), one user with its own role in
|
|
176
|
+
* parentheses when non-empty, two or three joined with commas and "and" (no roles — they bloat the
|
|
177
|
+
* line), or the first three plus a plain-text "+N more" beyond that. */
|
|
178
|
+
export function alsoUsedBySegments(entries: readonly AlsoUsedByEntry[]): UsedBySegment[] {
|
|
179
|
+
if (entries.length === 0) return [];
|
|
180
|
+
const segments: UsedBySegment[] = [{ text: 'Also used by ' }];
|
|
181
|
+
if (entries.length === 1) {
|
|
182
|
+
const [entry] = entries;
|
|
183
|
+
segments.push({ text: entry.component, component: entry.component });
|
|
184
|
+
if (entry.role) segments.push({ text: ` (${entry.role})` });
|
|
185
|
+
return segments;
|
|
186
|
+
}
|
|
187
|
+
const shown = entries.slice(0, 3);
|
|
188
|
+
shown.forEach((entry, i) => {
|
|
189
|
+
segments.push({ text: entry.component, component: entry.component });
|
|
190
|
+
if (i < shown.length - 2) segments.push({ text: ', ' });
|
|
191
|
+
else if (i === shown.length - 2) segments.push({ text: ' and ' });
|
|
192
|
+
});
|
|
193
|
+
if (entries.length > 3) segments.push({ text: ` +${entries.length - 3} more` });
|
|
194
|
+
return segments;
|
|
195
|
+
}
|
|
196
|
+
|
|
166
197
|
/** Escapes a string for literal use inside a `RegExp` (the characters the regex engine would
|
|
167
198
|
* otherwise treat as metacharacters). A Swift declaration name never contains any of these, but
|
|
168
199
|
* this keeps `linkifyRelated` correct even if one ever did. */
|
package/native/catalog/index.ts
CHANGED
|
@@ -48,7 +48,7 @@ export { defineCatalogPage, buildCatalogSections } from './pageApi';
|
|
|
48
48
|
export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRecord } from './pageApi';
|
|
49
49
|
export type {
|
|
50
50
|
PropDef, SectionDef, NavGroup, SpecimenSize, SpecimenSurfaceKind, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis,
|
|
51
|
-
PreviewWidths, TokenSection, ComposedOfEntry, CatalogMember, CatalogMemberSection, FamilyDef,
|
|
51
|
+
PreviewWidths, TokenSection, ComposedOfEntry, AlsoUsedByEntry, CatalogMember, CatalogMemberSection, FamilyDef,
|
|
52
52
|
CatalogAnatomy, CatalogAnatomyGroup, CatalogAnatomyItem, RelatedPage,
|
|
53
53
|
} from './types';
|
|
54
54
|
export { UpdatePanel } from './UpdatePanel';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
|
-
import type { CatalogAnatomy, CatalogMember, CatalogMemberSection, ComparisonDef, ComposedOfEntry, NavGroup, PreviewWidths, PropDef, RelatedPage, SectionDef, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot } from './types.ts';
|
|
2
|
+
import type { AlsoUsedByEntry, CatalogAnatomy, CatalogMember, CatalogMemberSection, ComparisonDef, ComposedOfEntry, NavGroup, PreviewWidths, PropDef, RelatedPage, SectionDef, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot } from './types.ts';
|
|
3
3
|
|
|
4
4
|
/** `SectionDef` without the derived fields (`id`/`path`/`props` come from detection and
|
|
5
5
|
* generated props), plus the three fields design §1 "Page shape" adds. */
|
|
@@ -447,6 +447,44 @@ export function buildCatalogSections<TId extends string>(
|
|
|
447
447
|
...pages.flatMap((page) => page.aliases ?? []),
|
|
448
448
|
]);
|
|
449
449
|
|
|
450
|
+
// A member only ever looks itself up (below) by its own canonical `id`, but `knownComposedOfNames`
|
|
451
|
+
// (N8, above) deliberately lets a `composedOf` entry's own `component` name a member's *alias*, or
|
|
452
|
+
// the member's `component` field instead of its `id` — both resolve as working links through
|
|
453
|
+
// `FamilyPage`'s own `resolvesAddress`. Without resolving through the same map here, an
|
|
454
|
+
// alias-named or component-named entry would link fine going forward but derive nothing going
|
|
455
|
+
// back: a missing "Also used by" line looks identical to "nothing uses this".
|
|
456
|
+
const canonicalMemberIdByAlias = new Map<string, string>();
|
|
457
|
+
for (const page of pages) {
|
|
458
|
+
for (const member of page.members ?? []) {
|
|
459
|
+
for (const alias of member.aliases ?? []) canonicalMemberIdByAlias.set(alias, member.id);
|
|
460
|
+
if (member.component && member.component !== member.id) canonicalMemberIdByAlias.set(member.component, member.id);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const canonicalUsedByTarget = (name: string) => canonicalMemberIdByAlias.get(name) ?? name;
|
|
464
|
+
|
|
465
|
+
// "Also used by" (derived, never authored): the inverse of every `composedOf` entry in the
|
|
466
|
+
// catalog whose `relationship` is `'built-in'` — the forward direction renders as "Uses X"
|
|
467
|
+
// (`UsesLine`); this is that same relationship read from the other end, keyed by the entry's own
|
|
468
|
+
// `component` (the used thing) — gathered once, up front, so a family member just looks up its
|
|
469
|
+
// own id below. A page's own `composedOf` attributes to that page's id (its address, e.g. a
|
|
470
|
+
// family's whole); a family member's own `composedOf` attributes to that member's own id.
|
|
471
|
+
const usedByTarget = new Map<string, Array<{ component: string; role: string }>>();
|
|
472
|
+
const addUsedBy = (target: string, component: string, role: string) => {
|
|
473
|
+
const list = usedByTarget.get(target);
|
|
474
|
+
if (list) list.push({ component, role });
|
|
475
|
+
else usedByTarget.set(target, [{ component, role }]);
|
|
476
|
+
};
|
|
477
|
+
for (const page of pages) {
|
|
478
|
+
for (const entry of page.composedOf ?? []) {
|
|
479
|
+
if (entry.relationship === 'built-in') addUsedBy(canonicalUsedByTarget(entry.component), page.id, entry.role);
|
|
480
|
+
}
|
|
481
|
+
for (const member of page.members ?? []) {
|
|
482
|
+
for (const entry of member.composedOf ?? []) {
|
|
483
|
+
if (entry.relationship === 'built-in') addUsedBy(canonicalUsedByTarget(entry.component), member.id, entry.role);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
450
488
|
// Every id/alias a family page's own `members` and `aliases` use, keyed by page id — gathered up
|
|
451
489
|
// front so `validateFamily` (above) can check catalog-wide uniqueness against every *other*
|
|
452
490
|
// page's ids.
|
|
@@ -507,6 +545,10 @@ export function buildCatalogSections<TId extends string>(
|
|
|
507
545
|
if ((globalThis as { __DEV__?: boolean }).__DEV__) {
|
|
508
546
|
for (const issue of issues) console.error(`[Catalog] ${issue}`);
|
|
509
547
|
}
|
|
548
|
+
// "Also used by": every id this page's own members (the whole included) use is excluded
|
|
549
|
+
// from its own members' derived users — a sibling (or the whole) composing a part is still
|
|
550
|
+
// the same family tree, already expressed by `parent`/the page itself, not a second user.
|
|
551
|
+
const pageMemberIds = new Set(page.members!.map((m) => m.id));
|
|
510
552
|
family = {
|
|
511
553
|
whole: page.whole!,
|
|
512
554
|
sections: page.sections!,
|
|
@@ -518,10 +560,24 @@ export function buildCatalogSections<TId extends string>(
|
|
|
518
560
|
relatedPages: page.relatedPages,
|
|
519
561
|
members: page.members!.map((member) => {
|
|
520
562
|
const memberComponent = member.component ? componentByName.get(member.component) : undefined;
|
|
563
|
+
// Dedupe by `component`, keeping the first role: page-level and whole-member `composedOf`
|
|
564
|
+
// attribute to the same string for a family's whole (`validateFamily` forces
|
|
565
|
+
// `whole === page.id`), and `validateComposedOf` has no duplicate check of its own, so the
|
|
566
|
+
// same user declared twice would otherwise render "Also used by P and P".
|
|
567
|
+
const dedupedUsers = new Map<string, { component: string; role: string }>();
|
|
568
|
+
for (const entry of usedByTarget.get(member.id) ?? []) {
|
|
569
|
+
if (pageMemberIds.has(entry.component)) continue;
|
|
570
|
+
if (!dedupedUsers.has(entry.component)) dedupedUsers.set(entry.component, entry);
|
|
571
|
+
}
|
|
572
|
+
const alsoUsedBy: AlsoUsedByEntry[] = [...dedupedUsers.values()].map((entry) => ({
|
|
573
|
+
component: entry.component,
|
|
574
|
+
role: entry.role || undefined,
|
|
575
|
+
}));
|
|
521
576
|
return {
|
|
522
577
|
...member,
|
|
523
578
|
props: memberComponent ? mergePropNotes(memberComponent, undefined) : undefined,
|
|
524
579
|
path: memberComponent?.file ?? member.source,
|
|
580
|
+
alsoUsedBy,
|
|
525
581
|
};
|
|
526
582
|
}),
|
|
527
583
|
};
|
package/native/catalog/types.ts
CHANGED
|
@@ -107,6 +107,19 @@ export interface ComposedOfEntry {
|
|
|
107
107
|
relationship: 'built-in' | 'slot' | 'related';
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/** Derived (never authored) — the inverse of every other page's or member's own `composedOf`
|
|
111
|
+
* entry whose `relationship` is `'built-in'`: if something else's `composedOf` names this member,
|
|
112
|
+
* that something is "also used by" it. The forward direction (that other `composedOf`) renders as
|
|
113
|
+
* "Uses X" (`UsesLine`); this is the same relationship read from the other end. Computed once, by
|
|
114
|
+
* `buildCatalogSections`, from data every author already declared from the other end — there is
|
|
115
|
+
* no field on `CatalogMember` or `CatalogPageInput` for this; a page can never author it directly. */
|
|
116
|
+
export interface AlsoUsedByEntry {
|
|
117
|
+
/** The other page's or member's own id — its catalog address. */
|
|
118
|
+
component: string;
|
|
119
|
+
/** That `ComposedOfEntry`'s own `role`, omitted when it's empty. */
|
|
120
|
+
role?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
110
123
|
/** One row or column heading in a grid. */
|
|
111
124
|
export interface ComparisonAxisItem {
|
|
112
125
|
key: string;
|
|
@@ -413,7 +426,11 @@ export interface RelatedPage {
|
|
|
413
426
|
* outline — set on `SectionDef.family` by `buildCatalogSections` when a page has `members`. */
|
|
414
427
|
export interface FamilyDef {
|
|
415
428
|
whole: string;
|
|
416
|
-
|
|
429
|
+
/** `alsoUsedBy` is derived — `buildCatalogSections` always populates it (`[]` when nothing else
|
|
430
|
+
* uses this member) — see `AlsoUsedByEntry`. Optional only so a hand-built `FamilyDef` (a demo,
|
|
431
|
+
* a fixture) that predates this field still type-checks; a reader treats an absent value the
|
|
432
|
+
* same as `[]`. */
|
|
433
|
+
members: Array<CatalogMember & { props?: PropDef[]; path: string; alsoUsedBy?: AlsoUsedByEntry[] }>;
|
|
417
434
|
sections: CatalogMemberSection[];
|
|
418
435
|
outline: Map<string, { number: string; level: number }>;
|
|
419
436
|
notShown?: string;
|