@krapwoo/ds-viewer 0.14.2 → 0.16.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 +249 -3
- package/config/index.ts +3 -0
- package/dist/cli/config.js +9 -1
- package/dist/cli/doctor.js +452 -19
- package/dist/cli/explain.js +26 -0
- package/dist/cli/staticPage.js +99 -10
- 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 +598 -23
- package/native/catalog/tokenDefinition.ts +430 -0
- package/native/catalog/types.ts +77 -5
- package/package.json +1 -1
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -0
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -0
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +1 -0
- package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +1 -0
- package/starter-kit/components/Divider/Divider.catalog.tsx +1 -0
- package/starter-kit/components/Pill/Pill.catalog.tsx +1 -0
- package/starter-kit/components/TopNav/TopNav.catalog.tsx +1 -0
|
@@ -5,7 +5,9 @@ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_MAX_CONTENT_WIDTH, CATALOG_RADIU
|
|
|
5
5
|
import { CatalogSidebar } from './CatalogSidebar';
|
|
6
6
|
import { SectionBlock } from './SectionBlock';
|
|
7
7
|
import { FamilyPage } from './FamilyPage';
|
|
8
|
+
import { GroupPage } from './GroupPage';
|
|
8
9
|
import { UpdatePanel } from './UpdatePanel';
|
|
10
|
+
import { SourceReferencePicker } from './SourceReferencePicker';
|
|
9
11
|
import {
|
|
10
12
|
aliasTargets,
|
|
11
13
|
DS_VIEWER_SECRET_HEADER,
|
|
@@ -19,6 +21,7 @@ import {
|
|
|
19
21
|
UPDATE_PAGE_ID,
|
|
20
22
|
type MemberSearchEntry,
|
|
21
23
|
} from './catalogNavigation';
|
|
24
|
+
import { browsableIds, buildReferenceIndex, hasReferenceNavigation, referenceLinkProps, resolveReference, type ReferenceEntry, type ReferenceNavigation } from './catalogReferences';
|
|
22
25
|
import { findOverlayNode, overlayAddressFromSearch } from './overlayViewport';
|
|
23
26
|
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
24
27
|
import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus } from './types';
|
|
@@ -27,6 +30,12 @@ import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus }
|
|
|
27
30
|
* constraint and a hardcoded string literal type at once, which is why this is its own alias. */
|
|
28
31
|
type ShellPageId<TId extends string> = TId | typeof UPDATE_PAGE_ID;
|
|
29
32
|
|
|
33
|
+
/** #60's "opening context" (plan Task 2B item 4): the page/member a Source reference journey
|
|
34
|
+
* started from, remembered only in memory (no persistent history manager) so a reference
|
|
35
|
+
* destination can offer a real `Back to <origin title>` link. Never an alias — always the actual
|
|
36
|
+
* page/member identity the user was looking at. */
|
|
37
|
+
type ReferenceOrigin<TId extends string> = { pageId: TId; memberId?: string };
|
|
38
|
+
|
|
30
39
|
function isWeb(): boolean {
|
|
31
40
|
return Platform.OS === 'web' && typeof window !== 'undefined';
|
|
32
41
|
}
|
|
@@ -128,6 +137,32 @@ function NotFoundPage({ raw, firstHref, onOpenFirst }: { raw: string; firstHref?
|
|
|
128
137
|
);
|
|
129
138
|
}
|
|
130
139
|
|
|
140
|
+
/** A quiet return or browse fallback. The callback owns activation in both shell modes,
|
|
141
|
+
* using the same modified-click policy as inline references. A same-family return can reuse
|
|
142
|
+
* this anchor as "Browse catalog" before the browser runs its default, so that default must
|
|
143
|
+
* never follow the newly rendered href after the intended origin has already been selected. */
|
|
144
|
+
function ReferenceReturnLink<TId extends string>({ label, targetId, memberId, onNavigate }: {
|
|
145
|
+
label: string;
|
|
146
|
+
targetId: TId;
|
|
147
|
+
memberId?: string;
|
|
148
|
+
onNavigate: (id: TId, memberId?: string) => void;
|
|
149
|
+
}) {
|
|
150
|
+
const activation = referenceLinkProps({
|
|
151
|
+
resolve: () => ({ pageId: targetId, memberId }),
|
|
152
|
+
open: onNavigate,
|
|
153
|
+
}, targetId);
|
|
154
|
+
return (
|
|
155
|
+
<Pressable
|
|
156
|
+
onPress={activation?.onPress}
|
|
157
|
+
accessibilityRole="link"
|
|
158
|
+
{...({ href: hashForId(memberId ?? targetId) } as Record<string, unknown>)}
|
|
159
|
+
style={notFoundStyles.link}
|
|
160
|
+
>
|
|
161
|
+
<Text style={notFoundStyles.linkText}>{label}</Text>
|
|
162
|
+
</Pressable>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
131
166
|
const notFoundStyles = StyleSheet.create({
|
|
132
167
|
headingTarget: { alignSelf: 'flex-start', marginBottom: 6 },
|
|
133
168
|
title: { ...CATALOG_TEXT.pageTitle, color: CATALOG_COLOR.text },
|
|
@@ -221,7 +256,19 @@ export function CatalogShell<TId extends string>({
|
|
|
221
256
|
|
|
222
257
|
const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
|
|
223
258
|
const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
|
|
224
|
-
|
|
259
|
+
// #60: the browse-facing subset only (reference-only pages, and their members, stay routable —
|
|
260
|
+
// see `knownAddressIds` below — but drop out of the sidebar/pager/hash-fallback sequence
|
|
261
|
+
// `order` itself is).
|
|
262
|
+
const browsable = useMemo(() => browsableIds(sections), [sections]);
|
|
263
|
+
const order = useMemo(() => orderedIds(groups, browsable, labels), [groups, browsable, labels]);
|
|
264
|
+
// #60: true once any page opts into the `'reference'` layer — the sidebar's own trigger for
|
|
265
|
+
// showing the separate Source reference entry at all; a legacy catalog with no such page keeps
|
|
266
|
+
// its present sidebar unchanged.
|
|
267
|
+
const hasReference = useMemo(() => hasReferenceNavigation(sections), [sections]);
|
|
268
|
+
// #60: the catalog-wide reference index the picker searches — built from every registered page,
|
|
269
|
+
// never filtered to `browsable`, so a page's family members are findable even when the page
|
|
270
|
+
// itself is reference-only.
|
|
271
|
+
const referenceEntries = useMemo(() => buildReferenceIndex(sections), [sections]);
|
|
225
272
|
// Spec §5.3: every family page's own member ids/aliases, resolved to their page (and member, for
|
|
226
273
|
// anything other than the whole) — used both for an old/alias/member address (`resolveRoute`)
|
|
227
274
|
// and for the sidebar's member search (`memberSearchEntries` below).
|
|
@@ -230,10 +277,18 @@ export function CatalogShell<TId extends string>({
|
|
|
230
277
|
// outside its own family — every other page's own id, plus every member id/alias `aliasMap`
|
|
231
278
|
// already resolves (`FamilyPage` itself always adds its own family's member ids on top of this).
|
|
232
279
|
const knownAddressIds = useMemo(() => new Set<string>([...sectionsById.keys(), ...aliasMap.keys()]), [sectionsById, aliasMap]);
|
|
280
|
+
// #60: every registered page id, independent of `order` (browse-only) — `resolveRoute` only
|
|
281
|
+
// ever uses this to decide whether a hash literally names a real page (never for display
|
|
282
|
+
// order), so a reference-only page's own address still resolves even though it's absent from
|
|
283
|
+
// `order`. The "Implementation seams" warning this plan carries: never repurpose the filtered
|
|
284
|
+
// browse set as the known-address set.
|
|
285
|
+
const fullRouteIds = useMemo(() => Array.from(sectionsById.keys()), [sectionsById]);
|
|
286
|
+
// #60: a reference-only family page's own members are excluded here too (not just `order`) —
|
|
287
|
+
// "Reference-only pages and their family member search hits stay out of ordinary browsing".
|
|
233
288
|
const memberIndex = useMemo(() => {
|
|
234
289
|
const map = new Map<string, string[]>();
|
|
235
290
|
for (const section of sections) {
|
|
236
|
-
if (!section.family) continue;
|
|
291
|
+
if (!section.family || !browsable.has(section.id)) continue;
|
|
237
292
|
const ids: string[] = [];
|
|
238
293
|
for (const member of section.family.members) {
|
|
239
294
|
ids.push(member.id, ...(member.aliases ?? []));
|
|
@@ -241,20 +296,28 @@ export function CatalogShell<TId extends string>({
|
|
|
241
296
|
map.set(section.id, ids);
|
|
242
297
|
}
|
|
243
298
|
return map;
|
|
244
|
-
}, [sections]);
|
|
299
|
+
}, [sections, browsable]);
|
|
245
300
|
const memberSearchEntries = useMemo(() => {
|
|
246
301
|
const entries: MemberSearchEntry<TId>[] = [];
|
|
247
302
|
for (const section of sections) {
|
|
248
|
-
if (!section.family) continue;
|
|
303
|
+
if (!section.family || !browsable.has(section.id)) continue;
|
|
249
304
|
for (const member of section.family.members) {
|
|
250
305
|
if (member.id === section.family.whole) continue;
|
|
251
306
|
entries.push({ pageId: section.id, memberId: member.id, aliases: member.aliases, familyTitle: section.title ?? section.id });
|
|
252
307
|
}
|
|
253
308
|
}
|
|
254
309
|
return entries;
|
|
255
|
-
}, [sections]);
|
|
310
|
+
}, [sections, browsable]);
|
|
311
|
+
|
|
312
|
+
// #60: the sidebar's own `groups` filtered to `browsable` — a reference-only page is never
|
|
313
|
+
// listed there regardless of whether the catalog author also left it out of `groups` (defense
|
|
314
|
+
// in depth, same reasoning as `memberIndex`/`memberSearchEntries` above).
|
|
315
|
+
const browsableGroups = useMemo(
|
|
316
|
+
() => groups.map((group) => ({ label: group.label, ids: group.ids.filter((id) => browsable.has(id)) })).filter((group) => group.ids.length > 0),
|
|
317
|
+
[groups, browsable],
|
|
318
|
+
);
|
|
256
319
|
|
|
257
|
-
const routeFromHash = useCallback((hash: string) => resolveRoute(hash,
|
|
320
|
+
const routeFromHash = useCallback((hash: string) => resolveRoute(hash, fullRouteIds, aliasMap), [fullRouteIds, aliasMap]);
|
|
258
321
|
|
|
259
322
|
const [active, setActive] = useState<ShellPageId<TId> | undefined>(() => {
|
|
260
323
|
if (embedded) return initialPage ?? order[0];
|
|
@@ -275,21 +338,65 @@ export function CatalogShell<TId extends string>({
|
|
|
275
338
|
// one-shot jump (`shouldRunMemberJump`) counts a re-selected member address as fresh — a plain
|
|
276
339
|
// `pendingMemberId` state update bails out (no re-render) when the string value is unchanged.
|
|
277
340
|
const [pendingMemberNonce, setPendingMemberNonce] = useState(0);
|
|
341
|
+
// #64 B5: the tokenDefinition section (if any) a page route's own `sectionId` named — set only
|
|
342
|
+
// by hash resolution (a section-alias route), never by `select` (an ordinary navigation never
|
|
343
|
+
// carries one). Consumed once by `SectionBlock`'s own scroll effect, the same one-shot-jump
|
|
344
|
+
// convention `pendingMemberId`/`pendingMemberNonce` already establishes for a member address.
|
|
345
|
+
const [pendingSectionId, setPendingSectionId] = useState<string | undefined>(() => {
|
|
346
|
+
if (embedded || !isWeb()) return undefined;
|
|
347
|
+
const route = routeFromHash(window.location.hash);
|
|
348
|
+
return route.kind === 'page' ? route.sectionId : undefined;
|
|
349
|
+
});
|
|
350
|
+
const [pendingSectionNonce, setPendingSectionNonce] = useState(0);
|
|
278
351
|
const [notFoundRaw, setNotFoundRaw] = useState<string | undefined>(() => {
|
|
279
352
|
if (embedded || !isWeb()) return undefined;
|
|
280
353
|
const route = routeFromHash(window.location.hash);
|
|
281
354
|
return route.kind === 'notFound' ? route.raw : undefined;
|
|
282
355
|
});
|
|
283
356
|
|
|
357
|
+
// #60: the Source reference picker's own open/close state, and the one journey it may have
|
|
358
|
+
// started (plan Task 2B item 4) — `undefined` means no recorded origin, which is also the
|
|
359
|
+
// truthful state for a directly loaded reference URL (no invented origin).
|
|
360
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
361
|
+
const [sourceOrigin, setSourceOrigin] = useState<ReferenceOrigin<TId> | undefined>(undefined);
|
|
362
|
+
// What page/member to remember as this journey's origin, captured the moment the picker opens
|
|
363
|
+
// (a ref, not state — opening never itself changes the route, so it must not re-render or touch
|
|
364
|
+
// `sourceOrigin` until an actual selection commits to a destination).
|
|
365
|
+
const pendingOriginRef = useRef<ReferenceOrigin<TId> | undefined>(undefined);
|
|
366
|
+
// A selected entry, parked here until the picker has actually finished closing (see the effect
|
|
367
|
+
// below) — the focus-ordering seam both `SourceReferencePicker.tsx` and this file's own module
|
|
368
|
+
// doc comment warn about: Modal's trigger-restore effect must run first, or it can restore the
|
|
369
|
+
// trigger focus OVER the destination this same selection is about to open.
|
|
370
|
+
const pendingReferenceSelectionRef = useRef<{ entry: ReferenceEntry<TId>; origin: ReferenceOrigin<TId> | undefined } | null>(null);
|
|
371
|
+
|
|
284
372
|
const scrollRef = useRef<ScrollView>(null);
|
|
285
373
|
const headingRef = useRef<View>(null);
|
|
286
374
|
const activeRef = useRef(active);
|
|
287
375
|
activeRef.current = active;
|
|
288
376
|
const pushHistoryNext = useRef(false);
|
|
289
377
|
const focusHeadingNext = useRef(false);
|
|
378
|
+
// #60 correction: the fragment-sync effect below writes `window.location.hash` itself to keep
|
|
379
|
+
// the URL in step with `active`/`pendingMemberId` — on web that write queues its own real
|
|
380
|
+
// `hashchange` event, which arrives at the very listener below meant for genuine external/manual/
|
|
381
|
+
// back-forward navigation. Recording the exact hash we just wrote (cleared the moment the next
|
|
382
|
+
// `hashchange` is seen, matched or not) lets that listener recognise its own settling event and
|
|
383
|
+
// skip it, instead of treating it as new navigation and erasing a just-recorded source origin.
|
|
384
|
+
const ownHashWriteRef = useRef<string | undefined>(undefined);
|
|
385
|
+
// The page/member the last scroll reset ran for — see the URL-sync effect.
|
|
386
|
+
const lastScrollTargetRef = useRef<{ active: ShellPageId<TId>; memberId: string | undefined } | undefined>(undefined);
|
|
290
387
|
|
|
291
388
|
const select = useCallback((id: ShellPageId<TId>, memberId?: string) => {
|
|
389
|
+
// Every ordinary navigation — sidebar, pager, a member hit, the recovery/banner/return links
|
|
390
|
+
// below — starts a fresh page, so any previously recorded Source reference origin is now
|
|
391
|
+
// stale (plan Task 2B item 4: "must not leave a stale source-return control on unrelated
|
|
392
|
+
// pages"). A real selection from the picker re-records it afterwards (see the effect below),
|
|
393
|
+
// in the same batch, so that later call always wins.
|
|
394
|
+
setSourceOrigin(undefined);
|
|
292
395
|
setNotFoundRaw(undefined);
|
|
396
|
+
// #64 B5: `select` is always an ordinary navigation (sidebar, pager, a member hit, …), never a
|
|
397
|
+
// section-alias arrival — any section scroll still owed from a previous hash resolution is now
|
|
398
|
+
// stale.
|
|
399
|
+
setPendingSectionId(undefined);
|
|
293
400
|
if (id === activeRef.current) {
|
|
294
401
|
if (memberId) {
|
|
295
402
|
pushHistoryNext.current = true;
|
|
@@ -308,20 +415,80 @@ export function CatalogShell<TId extends string>({
|
|
|
308
415
|
setActive(id);
|
|
309
416
|
}, []);
|
|
310
417
|
|
|
418
|
+
// Task 2B2 correction: the one private reference-selection routine both the picker's deferred
|
|
419
|
+
// selection (below) and inline reference activation (`referenceNavigation.open`, further below)
|
|
420
|
+
// share — `select` alone always clears any recorded origin (every *ordinary* navigation must),
|
|
421
|
+
// so this is what actually records a truthful one afterwards, in the same batch: a self-
|
|
422
|
+
// referential selection (destination already equals the recorded origin) records no origin at
|
|
423
|
+
// all, the same "no self-referential Back link" rule the picker already established.
|
|
424
|
+
const openReference = useCallback(
|
|
425
|
+
(pageId: TId, memberId: string | undefined, origin: ReferenceOrigin<TId> | undefined) => {
|
|
426
|
+
const sameAsOrigin = origin !== undefined && origin.pageId === pageId && origin.memberId === memberId;
|
|
427
|
+
select(pageId, memberId);
|
|
428
|
+
setSourceOrigin(sameAsOrigin ? undefined : origin);
|
|
429
|
+
},
|
|
430
|
+
[select],
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
// Task 2B2: the shared navigation `SectionBlock`/`FamilyPage` use to turn an already-true
|
|
434
|
+
// reference fact (a Composition name, an "also used by" user, a `related`/anatomy name) into a
|
|
435
|
+
// real canonical link — through the exact same `resolveReference`/`openReference` this shell's
|
|
436
|
+
// own Source reference journey already uses, never a second lookup or a global context. Supplied
|
|
437
|
+
// to every active page's own renderer below, independent of `hasReference` (plan: "CatalogShell
|
|
438
|
+
// supplies optional reference-navigation behavior to its own ordinary/family renderers,
|
|
439
|
+
// independent of whether reference-only navigation metadata exists").
|
|
440
|
+
const referenceNavigation: ReferenceNavigation<TId> = useMemo(
|
|
441
|
+
() => ({
|
|
442
|
+
resolve: (name) => {
|
|
443
|
+
const result = resolveReference(name, fullRouteIds, aliasMap, referenceEntries);
|
|
444
|
+
return result.kind === 'address' ? { pageId: result.pageId, memberId: result.memberId } : undefined;
|
|
445
|
+
},
|
|
446
|
+
// The inline origin is the page the reference was actually clicked from — never the stale
|
|
447
|
+
// `pendingMemberId` of a previously selected, unrelated member (plan correction R1: "a
|
|
448
|
+
// page-level anatomy/relatedPages reference starts at the page"). `sourceMemberId`, when the
|
|
449
|
+
// calling renderer supplied one, narrows that to the actual owning part/whole instead — but
|
|
450
|
+
// only once validated against the current page's own real family; an unknown hint can never
|
|
451
|
+
// fabricate an origin.
|
|
452
|
+
open: (pageId, memberId, sourceMemberId) => {
|
|
453
|
+
const current = active !== undefined && active !== UPDATE_PAGE_ID ? active : undefined;
|
|
454
|
+
if (current === undefined) {
|
|
455
|
+
openReference(pageId, memberId, undefined);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const ownerFamily = sectionsById.get(current)?.family;
|
|
459
|
+
const validHint = sourceMemberId !== undefined && ownerFamily?.members.some((member) => member.id === sourceMemberId) ? sourceMemberId : undefined;
|
|
460
|
+
openReference(pageId, memberId, { pageId: current, memberId: validHint });
|
|
461
|
+
},
|
|
462
|
+
}),
|
|
463
|
+
[fullRouteIds, aliasMap, referenceEntries, active, sectionsById, openReference],
|
|
464
|
+
);
|
|
465
|
+
|
|
311
466
|
// Back/forward, manual fragment edits, and our own hash assignments below (which also fire
|
|
312
467
|
// `hashchange`) all funnel through here.
|
|
313
468
|
useEffect(() => {
|
|
314
469
|
if (!routesByUrl) return;
|
|
315
470
|
const onHashChange = () => {
|
|
471
|
+
// This event may just be our own fragment-sync write (below) settling, not new navigation —
|
|
472
|
+
// state already reflects that write, so there is nothing to reconcile and no stale origin to
|
|
473
|
+
// clear. One-shot: consumed (and cleared) on the very next event, matched or not, so a later
|
|
474
|
+
// genuine navigation that happens to land on the same hash is never mistaken for our own.
|
|
475
|
+
const ownWrite = ownHashWriteRef.current !== undefined && ownHashWriteRef.current === window.location.hash;
|
|
476
|
+
ownHashWriteRef.current = undefined;
|
|
477
|
+
if (ownWrite) return;
|
|
478
|
+
// Back/forward and manual fragment edits bypass `select` entirely — clear any recorded
|
|
479
|
+
// Source reference origin here too, for the same "no stale return control" reason.
|
|
480
|
+
setSourceOrigin(undefined);
|
|
316
481
|
const route = routeFromHash(window.location.hash);
|
|
317
482
|
if (route.kind === 'notFound') {
|
|
318
483
|
setNotFoundRaw(route.raw);
|
|
319
484
|
setPendingMemberId(undefined);
|
|
485
|
+
setPendingSectionId(undefined);
|
|
320
486
|
return;
|
|
321
487
|
}
|
|
322
488
|
setNotFoundRaw(undefined);
|
|
323
489
|
if (route.kind === 'empty') {
|
|
324
490
|
setPendingMemberId(undefined);
|
|
491
|
+
setPendingSectionId(undefined);
|
|
325
492
|
if (order[0] !== undefined && order[0] !== activeRef.current) {
|
|
326
493
|
focusHeadingNext.current = true;
|
|
327
494
|
setActive(order[0]);
|
|
@@ -330,6 +497,7 @@ export function CatalogShell<TId extends string>({
|
|
|
330
497
|
}
|
|
331
498
|
if (route.kind === 'update') {
|
|
332
499
|
setPendingMemberId(undefined);
|
|
500
|
+
setPendingSectionId(undefined);
|
|
333
501
|
if (UPDATE_PAGE_ID !== activeRef.current) {
|
|
334
502
|
focusHeadingNext.current = true;
|
|
335
503
|
setActive(UPDATE_PAGE_ID);
|
|
@@ -338,6 +506,12 @@ export function CatalogShell<TId extends string>({
|
|
|
338
506
|
}
|
|
339
507
|
if (route.kind === 'page') {
|
|
340
508
|
setPendingMemberId(undefined);
|
|
509
|
+
// #64 B5: a section-alias route re-scrolls even to the page it's already on (a plain
|
|
510
|
+
// `pendingSectionId` string update would otherwise bail out when re-visiting the same
|
|
511
|
+
// section) — bump the nonce whenever one is named, the same "even a repeat one" reasoning
|
|
512
|
+
// `pendingMemberNonce` already uses for a member address.
|
|
513
|
+
setPendingSectionId(route.sectionId);
|
|
514
|
+
if (route.sectionId) setPendingSectionNonce((n) => n + 1);
|
|
341
515
|
if (route.id !== activeRef.current) {
|
|
342
516
|
focusHeadingNext.current = true;
|
|
343
517
|
setActive(route.id);
|
|
@@ -346,6 +520,7 @@ export function CatalogShell<TId extends string>({
|
|
|
346
520
|
}
|
|
347
521
|
// member: scroll/open/focus is the member itself (FamilyPage's own effect), never the page
|
|
348
522
|
// heading — even when this also switches the active page.
|
|
523
|
+
setPendingSectionId(undefined);
|
|
349
524
|
setPendingMemberId(route.memberId);
|
|
350
525
|
setPendingMemberNonce((n) => n + 1);
|
|
351
526
|
if (route.pageId !== activeRef.current) {
|
|
@@ -366,21 +541,73 @@ export function CatalogShell<TId extends string>({
|
|
|
366
541
|
const focus = focusHeadingNext.current;
|
|
367
542
|
pushHistoryNext.current = false;
|
|
368
543
|
focusHeadingNext.current = false;
|
|
369
|
-
|
|
544
|
+
// F7: while a section-alias arrival is live (`pendingSectionId` is cleared by every other
|
|
545
|
+
// navigation), the URL already names that section — leave the alias hash alone so a reload or
|
|
546
|
+
// a copied link keeps the section instead of collapsing to the page id.
|
|
547
|
+
const keepSectionAliasHash = pendingMemberId === undefined && pendingSectionId !== undefined;
|
|
548
|
+
if (routesByUrl && !keepSectionAliasHash) {
|
|
370
549
|
const expected = hashForId(pendingMemberId ?? active);
|
|
371
550
|
if (window.location.hash !== expected) {
|
|
551
|
+
ownHashWriteRef.current = expected;
|
|
372
552
|
if (push) window.location.hash = expected;
|
|
373
553
|
else window.history.replaceState(null, '', expected);
|
|
374
554
|
}
|
|
375
555
|
}
|
|
376
|
-
|
|
556
|
+
// Only a real page or member change returns to the top. A change of `pendingSectionId` alone
|
|
557
|
+
// (a same-page section-alias arrival) re-runs this effect for the hash above, but the section
|
|
558
|
+
// has already scrolled itself into view (child effects run first), so resetting would undo it.
|
|
559
|
+
const previous = lastScrollTargetRef.current;
|
|
560
|
+
lastScrollTargetRef.current = { active, memberId: pendingMemberId };
|
|
561
|
+
const targetChanged = previous === undefined || previous.active !== active || previous.memberId !== pendingMemberId;
|
|
562
|
+
if (!pendingMemberId && targetChanged) {
|
|
377
563
|
resetMainScroll(scrollRef.current);
|
|
378
564
|
if (focus) focusElement(headingRef.current);
|
|
379
565
|
}
|
|
380
|
-
}, [active, pendingMemberId, routesByUrl]);
|
|
566
|
+
}, [active, pendingMemberId, pendingSectionId, routesByUrl]);
|
|
567
|
+
|
|
568
|
+
// #60: captures the current page/member as this journey's origin (before the picker has
|
|
569
|
+
// changed anything), then opens it. A cancellation (Escape/Close) never reaches the effect
|
|
570
|
+
// below, so nothing it captured here is ever applied.
|
|
571
|
+
const openSourceReferencePicker = useCallback(() => {
|
|
572
|
+
pendingOriginRef.current = active !== undefined && active !== UPDATE_PAGE_ID ? { pageId: active, memberId: pendingMemberId } : undefined;
|
|
573
|
+
setPickerOpen(true);
|
|
574
|
+
}, [active, pendingMemberId]);
|
|
575
|
+
|
|
576
|
+
const dismissSourceReferencePicker = useCallback(() => setPickerOpen(false), []);
|
|
577
|
+
|
|
578
|
+
// The picker leaf never navigates itself (`SourceReferencePicker.tsx`'s own module doc
|
|
579
|
+
// comment) — only parks the choice and closes. The effect below applies it once closed.
|
|
580
|
+
const selectReference = useCallback((entry: ReferenceEntry<TId>) => {
|
|
581
|
+
pendingReferenceSelectionRef.current = { entry, origin: pendingOriginRef.current };
|
|
582
|
+
setPickerOpen(false);
|
|
583
|
+
}, []);
|
|
584
|
+
|
|
585
|
+
// Runs only after the picker has actually finished closing — React commits a child's (the
|
|
586
|
+
// Modal's) own closing/trigger-restore effects before this component's own effect, so the
|
|
587
|
+
// destination's focus below can never be clobbered by that restore (the ordering hazard this
|
|
588
|
+
// file's own module doc comment and `SourceReferencePicker.tsx`'s both warn about).
|
|
589
|
+
useEffect(() => {
|
|
590
|
+
if (pickerOpen) return;
|
|
591
|
+
const pending = pendingReferenceSelectionRef.current;
|
|
592
|
+
if (!pending) return;
|
|
593
|
+
pendingReferenceSelectionRef.current = null;
|
|
594
|
+
const { entry, origin } = pending;
|
|
595
|
+
openReference(entry.pageId, entry.memberId, origin);
|
|
596
|
+
}, [pickerOpen, openReference]);
|
|
381
597
|
|
|
382
598
|
const activeDef = active !== undefined && active !== UPDATE_PAGE_ID ? sectionsById.get(active) : undefined;
|
|
383
599
|
|
|
600
|
+
// Return to the recorded source-lookup origin for any destination. A directly opened
|
|
601
|
+
// reference-only page has no origin and may instead offer the first real browsing page.
|
|
602
|
+
const referenceReturn = useMemo(() => {
|
|
603
|
+
if (!activeDef) return undefined;
|
|
604
|
+
if (sourceOrigin) {
|
|
605
|
+
return { label: `Back to ${labels.get(sourceOrigin.pageId) ?? sourceOrigin.pageId}`, targetId: sourceOrigin.pageId, memberId: sourceOrigin.memberId };
|
|
606
|
+
}
|
|
607
|
+
if (activeDef.navigation === 'reference' && order[0] !== undefined) return { label: 'Browse catalog', targetId: order[0], memberId: undefined };
|
|
608
|
+
return undefined;
|
|
609
|
+
}, [activeDef, sourceOrigin, labels, order]);
|
|
610
|
+
|
|
384
611
|
// The live version status: starts from what `dev` wrote at startup, then follows the local
|
|
385
612
|
// endpoint (a reload after **Check now** must not fall back to the startup snapshot).
|
|
386
613
|
const [liveStatus, setLiveStatus] = useState<VersionStatus | null>(versionStatus ?? null);
|
|
@@ -435,7 +662,7 @@ export function CatalogShell<TId extends string>({
|
|
|
435
662
|
logo={appName}
|
|
436
663
|
logoImageSource={logoImageSource}
|
|
437
664
|
caption={title}
|
|
438
|
-
groups={
|
|
665
|
+
groups={browsableGroups}
|
|
439
666
|
labels={labels}
|
|
440
667
|
memberIndex={memberIndex}
|
|
441
668
|
memberSearchEntries={memberSearchEntries}
|
|
@@ -443,8 +670,10 @@ export function CatalogShell<TId extends string>({
|
|
|
443
670
|
onPress={select}
|
|
444
671
|
onSelectMember={(pageId, memberId) => select(pageId, memberId)}
|
|
445
672
|
footer={footer}
|
|
673
|
+
sourceReference={hasReference ? { onPress: openSourceReferencePicker } : undefined}
|
|
446
674
|
keepDocumentUrl={embedded}
|
|
447
675
|
/>
|
|
676
|
+
<SourceReferencePicker visible={pickerOpen} entries={referenceEntries} onDismiss={dismissSourceReferencePicker} onSelect={selectReference} />
|
|
448
677
|
<ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
|
|
449
678
|
{notFoundRaw !== undefined ? (
|
|
450
679
|
<NotFoundPage raw={notFoundRaw} firstHref={order[0] !== undefined ? hashForId(order[0]) : undefined} onOpenFirst={() => order[0] !== undefined && select(order[0])} />
|
|
@@ -457,44 +686,87 @@ export function CatalogShell<TId extends string>({
|
|
|
457
686
|
appName={appName}
|
|
458
687
|
headingRef={headingRef}
|
|
459
688
|
/>
|
|
689
|
+
) : activeDef?.family?.kind === 'group' ? (
|
|
690
|
+
<React.Fragment key={activeDef.id}>
|
|
691
|
+
{referenceReturn && <ReferenceReturnLink label={referenceReturn.label} targetId={referenceReturn.targetId} memberId={referenceReturn.memberId} onNavigate={select} />}
|
|
692
|
+
<GroupPage
|
|
693
|
+
def={activeDef}
|
|
694
|
+
groupLabel={groupLabelFor(groups, activeDef.id)}
|
|
695
|
+
breadcrumbRoot={appName}
|
|
696
|
+
banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} keepDocumentUrl={embedded} /> : undefined}
|
|
697
|
+
pager={{
|
|
698
|
+
previousId: neighbors(order, activeDef.id).previous,
|
|
699
|
+
nextId: neighbors(order, activeDef.id).next,
|
|
700
|
+
onNavigate: select,
|
|
701
|
+
labelOf: (id) => labels.get(id) ?? id,
|
|
702
|
+
}}
|
|
703
|
+
headingRef={headingRef}
|
|
704
|
+
defaultPreviewWidths={defaultPreviewWidths}
|
|
705
|
+
focusMemberId={pendingMemberId}
|
|
706
|
+
focusNonce={pendingMemberNonce}
|
|
707
|
+
knownAddressIds={knownAddressIds}
|
|
708
|
+
navigation={referenceNavigation}
|
|
709
|
+
/>
|
|
710
|
+
</React.Fragment>
|
|
460
711
|
) : activeDef?.family ? (
|
|
461
|
-
<
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
712
|
+
<React.Fragment key={activeDef.id}>
|
|
713
|
+
{referenceReturn && <ReferenceReturnLink label={referenceReturn.label} targetId={referenceReturn.targetId} memberId={referenceReturn.memberId} onNavigate={select} />}
|
|
714
|
+
<FamilyPage
|
|
715
|
+
def={activeDef}
|
|
716
|
+
groupLabel={groupLabelFor(groups, activeDef.id)}
|
|
717
|
+
breadcrumbRoot={appName}
|
|
718
|
+
banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} keepDocumentUrl={embedded} /> : undefined}
|
|
719
|
+
pager={{
|
|
720
|
+
previousId: neighbors(order, activeDef.id).previous,
|
|
721
|
+
nextId: neighbors(order, activeDef.id).next,
|
|
722
|
+
onNavigate: select,
|
|
723
|
+
labelOf: (id) => labels.get(id) ?? id,
|
|
724
|
+
}}
|
|
725
|
+
headingRef={headingRef}
|
|
726
|
+
defaultPreviewWidths={defaultPreviewWidths}
|
|
727
|
+
focusMemberId={pendingMemberId}
|
|
728
|
+
focusNonce={pendingMemberNonce}
|
|
729
|
+
knownAddressIds={knownAddressIds}
|
|
730
|
+
navigation={referenceNavigation}
|
|
731
|
+
/>
|
|
732
|
+
</React.Fragment>
|
|
479
733
|
) : activeDef ? (
|
|
480
|
-
<
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
734
|
+
<React.Fragment key={activeDef.id}>
|
|
735
|
+
{referenceReturn && <ReferenceReturnLink label={referenceReturn.label} targetId={referenceReturn.targetId} memberId={referenceReturn.memberId} onNavigate={select} />}
|
|
736
|
+
<SectionBlock
|
|
737
|
+
def={activeDef}
|
|
738
|
+
groupLabel={groupLabelFor(groups, activeDef.id)}
|
|
739
|
+
breadcrumbRoot={appName}
|
|
740
|
+
// The approved mockup's own `PAGE_BG` order: crumb, then banner, then title — the
|
|
741
|
+
// banner used to render above the breadcrumb instead (Important finding, Fable
|
|
742
|
+
// correction pass).
|
|
743
|
+
banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} keepDocumentUrl={embedded} /> : undefined}
|
|
744
|
+
pager={{
|
|
745
|
+
previousId: neighbors(order, activeDef.id).previous,
|
|
746
|
+
nextId: neighbors(order, activeDef.id).next,
|
|
747
|
+
onNavigate: select,
|
|
748
|
+
labelOf: (id) => labels.get(id) ?? id,
|
|
749
|
+
}}
|
|
750
|
+
headingRef={headingRef}
|
|
751
|
+
defaultPreviewWidths={defaultPreviewWidths}
|
|
752
|
+
navigation={referenceNavigation}
|
|
753
|
+
focusSectionId={pendingSectionId}
|
|
754
|
+
focusNonce={pendingSectionNonce}
|
|
755
|
+
/>
|
|
756
|
+
</React.Fragment>
|
|
757
|
+
) : hasReference ? (
|
|
758
|
+
// #60 correction: an all-reference catalog with no `initialPage`/browsable page has
|
|
759
|
+
// `active === undefined` here too, same as a genuinely empty one — but every one of its
|
|
760
|
+
// registered reference pages is real and selectable, so claiming "No catalog pages are
|
|
761
|
+
// available" would be false. No destination is fabricated (there is no browsable page to
|
|
762
|
+
// invent one from); this only ever offers the real Source reference action already wired
|
|
763
|
+
// above, never a second picker or a guessed route.
|
|
764
|
+
<View>
|
|
765
|
+
<Text style={styles.empty}>No pages are set up for ordinary browsing yet.</Text>
|
|
766
|
+
<Pressable onPress={openSourceReferencePicker} accessibilityRole="button" style={notFoundStyles.link}>
|
|
767
|
+
<Text style={notFoundStyles.linkText}>Open source reference</Text>
|
|
768
|
+
</Pressable>
|
|
769
|
+
</View>
|
|
498
770
|
) : (
|
|
499
771
|
<Text style={styles.empty}>No catalog pages are available.</Text>
|
|
500
772
|
)}
|
|
@@ -75,6 +75,31 @@ function MemberResultItem({ label, familyTitle, href, onPress, keepDocumentUrl =
|
|
|
75
75
|
);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** #60's quiet Source reference entry (plan Task 2B, "the accepted A access model"): opens the
|
|
79
|
+
* catalog-wide picker modal, so — unlike every other row here — it is a real button, not a link
|
|
80
|
+
* (no `href`, no navigation of its own). Rendered outside `filtered`/`memberHits` so a filter that
|
|
81
|
+
* matches no page never hides it (`CatalogShell` only passes `sourceReference` at all once
|
|
82
|
+
* `hasReferenceNavigation` says the catalog has one). Reuses the plain nav-row visual `NavItem`
|
|
83
|
+
* already uses. */
|
|
84
|
+
function SourceReferenceEntry({ onPress }: { onPress: () => void }) {
|
|
85
|
+
const [hovered, setHovered] = useState(false);
|
|
86
|
+
const [focused, setFocused] = useState(false);
|
|
87
|
+
return (
|
|
88
|
+
<Pressable
|
|
89
|
+
onPress={onPress}
|
|
90
|
+
onHoverIn={() => setHovered(true)}
|
|
91
|
+
onHoverOut={() => setHovered(false)}
|
|
92
|
+
onFocus={() => setFocused(true)}
|
|
93
|
+
onBlur={() => setFocused(false)}
|
|
94
|
+
accessibilityRole="button"
|
|
95
|
+
accessibilityLabel="Open source reference"
|
|
96
|
+
style={({ pressed }) => [styles.item, styles.sourceReferenceItem, (pressed || hovered) && styles.itemHover, focused && styles.focusRing]}
|
|
97
|
+
>
|
|
98
|
+
<Text style={styles.label}>Source reference</Text>
|
|
99
|
+
</Pressable>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
78
103
|
function FooterLink({ label, kind, active, onPress, keepDocumentUrl = false }: { label: string; kind: 'update' | 'version'; active: boolean; onPress: () => void; keepDocumentUrl?: boolean }) {
|
|
79
104
|
const [hovered, setHovered] = useState(false);
|
|
80
105
|
const [focused, setFocused] = useState(false);
|
|
@@ -119,6 +144,7 @@ export function CatalogSidebar<TId extends string>({
|
|
|
119
144
|
onPress,
|
|
120
145
|
onSelectMember,
|
|
121
146
|
footer,
|
|
147
|
+
sourceReference,
|
|
122
148
|
keepDocumentUrl = false,
|
|
123
149
|
}: {
|
|
124
150
|
logo: string;
|
|
@@ -148,6 +174,10 @@ export function CatalogSidebar<TId extends string>({
|
|
|
148
174
|
* or the update notice with a dot (`'update'`, design §5's footer line). `active` is true while
|
|
149
175
|
* the update page itself is open — it gets the same active styling a selected nav item would. */
|
|
150
176
|
footer?: { label: string; kind?: 'update' | 'version'; active: boolean; onPress: () => void };
|
|
177
|
+
/** #60's catalog-wide Source reference entry: present only once `CatalogShell` has decided (via
|
|
178
|
+
* `hasReferenceNavigation`) that the catalog has at least one `'reference'` page. Omitted keeps
|
|
179
|
+
* the legacy sidebar unchanged. */
|
|
180
|
+
sourceReference?: { onPress: () => void };
|
|
151
181
|
}) {
|
|
152
182
|
const [query, setQuery] = useState('');
|
|
153
183
|
// A bundled local image (the only kind `cli/workspace.ts` ever produces — see its doc comment
|
|
@@ -238,7 +268,12 @@ export function CatalogSidebar<TId extends string>({
|
|
|
238
268
|
|
|
239
269
|
<CatalogSearchInput value={query} onChangeText={setQuery} placeholder="Filter components…" />
|
|
240
270
|
|
|
241
|
-
{filtered
|
|
271
|
+
{/* Outside `filtered`/`memberHits`: a filter that matches no page must not hide this. */}
|
|
272
|
+
{sourceReference && <SourceReferenceEntry onPress={sourceReference.onPress} />}
|
|
273
|
+
|
|
274
|
+
{/* Preserve omitted-metadata catalogs; only source-enabled empty lists suppress an
|
|
275
|
+
unfiltered no-match message. A genuine filtered search still reports no matches. */}
|
|
276
|
+
{(!sourceReference || query.trim() !== '') && filtered.length === 0 && memberHits.length === 0 && <Text style={styles.empty}>No matches</Text>}
|
|
242
277
|
{/* V2c item 10: pages first, member hits below them — matching `memberSearchEntries`'s own
|
|
243
278
|
doc comment ("below the filtered pages") and the spec's "the sidebar lists family pages
|
|
244
279
|
only, never members" emphasis. */}
|
|
@@ -299,6 +334,7 @@ const styles = StyleSheet.create({
|
|
|
299
334
|
inlineHeaderTop: { alignItems: 'flex-start' },
|
|
300
335
|
inlineText: { flex: 1, minWidth: 0 },
|
|
301
336
|
empty: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, paddingHorizontal: CATALOG_LAYOUT.navItemPaddingX, paddingVertical: 8 },
|
|
337
|
+
sourceReferenceItem: { marginBottom: CATALOG_SPACE.sm },
|
|
302
338
|
footerContainer: {
|
|
303
339
|
borderTopWidth: 1,
|
|
304
340
|
borderTopColor: CATALOG_COLOR.borderHairline,
|