@krapwoo/ds-viewer 0.9.1 → 0.11.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 +82 -0
- package/dist/cli/doctor.js +182 -3
- 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 +67 -12
- package/native/catalog/BoundedOverlayViewport.tsx +2 -2
- package/native/catalog/CatalogButton.tsx +2 -2
- package/native/catalog/CatalogSearchInput.tsx +3 -3
- package/native/catalog/CatalogShell.tsx +213 -32
- package/native/catalog/CatalogSidebar.tsx +69 -19
- package/native/catalog/ComparisonGrid.tsx +4 -4
- package/native/catalog/ComparisonGroups.tsx +3 -3
- package/native/catalog/ComparisonList.tsx +2 -2
- package/native/catalog/FamilyPage.tsx +1188 -0
- package/native/catalog/MotionSpecimen.tsx +3 -3
- package/native/catalog/PhoneFrame.tsx +2 -2
- package/native/catalog/PropsTable.tsx +7 -7
- package/native/catalog/ReferenceDetails.tsx +33 -22
- package/native/catalog/SectionBlock.tsx +57 -27
- package/native/catalog/SpacingScaleGallery.tsx +3 -3
- package/native/catalog/Swatch.tsx +3 -3
- package/native/catalog/TokenLayouts.tsx +6 -9
- package/native/catalog/TokenRow.tsx +2 -2
- package/native/catalog/TypeScaleGallery.tsx +2 -2
- package/native/catalog/UpdatePanel.tsx +29 -30
- package/native/catalog/VariantGroup.tsx +3 -6
- 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 +8 -2
- package/native/catalog/overlayViewport.ts +30 -10
- package/native/catalog/pageApi.ts +357 -15
- package/native/catalog/tokens.ts +70 -12
- package/native/catalog/types.ts +106 -0
- package/package.json +1 -1
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { AccessibilityInfo, Platform, Pressable, ScrollView, StyleSheet, Text, View, findNodeHandle } from 'react-native';
|
|
3
3
|
import type { ImageSourcePropType } from 'react-native';
|
|
4
|
-
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_MAX_CONTENT_WIDTH, CATALOG_RADIUS, CATALOG_SPACE,
|
|
4
|
+
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_MAX_CONTENT_WIDTH, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
|
|
5
5
|
import { CatalogSidebar } from './CatalogSidebar';
|
|
6
6
|
import { SectionBlock } from './SectionBlock';
|
|
7
|
+
import { FamilyPage } from './FamilyPage';
|
|
7
8
|
import { UpdatePanel } from './UpdatePanel';
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
aliasTargets,
|
|
11
|
+
DS_VIEWER_SECRET_HEADER,
|
|
12
|
+
footerLink,
|
|
13
|
+
groupLabelFor,
|
|
14
|
+
hashForId,
|
|
15
|
+
neighbors,
|
|
16
|
+
orderedIds,
|
|
17
|
+
resolveRoute,
|
|
18
|
+
shouldShowMajorBanner,
|
|
19
|
+
UPDATE_PAGE_ID,
|
|
20
|
+
type MemberSearchEntry,
|
|
21
|
+
} from './catalogNavigation';
|
|
9
22
|
import { findOverlayNode, overlayAddressFromSearch } from './overlayViewport';
|
|
10
23
|
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
11
24
|
import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus } from './types';
|
|
@@ -88,6 +101,41 @@ function MajorBanner({ update, onReviewUpdate, onDismiss, keepDocumentUrl = fals
|
|
|
88
101
|
);
|
|
89
102
|
}
|
|
90
103
|
|
|
104
|
+
/** Spec §5.3 "Unknown or malformed address": shown instead of silently falling back to the first
|
|
105
|
+
* page, for every page (not only family pages). Focuses its own title on mount — unlike a normal
|
|
106
|
+
* page change, this can be the very first thing a fresh load shows, so it can't wait for the
|
|
107
|
+
* shared "focus only when the user navigated" logic the rest of the shell uses. */
|
|
108
|
+
function NotFoundPage({ raw, firstHref, onOpenFirst }: { raw: string; firstHref?: string; onOpenFirst: () => void }) {
|
|
109
|
+
const ref = useRef<View>(null);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
focusElement(ref.current);
|
|
112
|
+
}, []);
|
|
113
|
+
return (
|
|
114
|
+
<View>
|
|
115
|
+
<View ref={ref} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={notFoundStyles.headingTarget}>
|
|
116
|
+
<Text style={notFoundStyles.title}>Page not found</Text>
|
|
117
|
+
</View>
|
|
118
|
+
<Text style={notFoundStyles.body}>{`There's no catalog page at #${raw}.`}</Text>
|
|
119
|
+
<Pressable
|
|
120
|
+
onPress={onOpenFirst}
|
|
121
|
+
accessibilityRole="link"
|
|
122
|
+
{...(firstHref ? ({ href: firstHref } as Record<string, unknown>) : null)}
|
|
123
|
+
style={notFoundStyles.link}
|
|
124
|
+
>
|
|
125
|
+
<Text style={notFoundStyles.linkText}>Open the first page</Text>
|
|
126
|
+
</Pressable>
|
|
127
|
+
</View>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const notFoundStyles = StyleSheet.create({
|
|
132
|
+
headingTarget: { alignSelf: 'flex-start', marginBottom: 6 },
|
|
133
|
+
title: { ...CATALOG_TEXT.pageTitle, color: CATALOG_COLOR.text },
|
|
134
|
+
body: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, marginBottom: CATALOG_SPACE.lg },
|
|
135
|
+
link: { alignSelf: 'flex-start', minHeight: CATALOG_LAYOUT.controlSize, justifyContent: 'center' },
|
|
136
|
+
linkText: { ...CATALOG_TEXT.bodyStrong, color: CATALOG_COLOR.accent },
|
|
137
|
+
});
|
|
138
|
+
|
|
91
139
|
const bannerStyles = StyleSheet.create({
|
|
92
140
|
banner: {
|
|
93
141
|
flexDirection: 'row',
|
|
@@ -101,11 +149,11 @@ const bannerStyles = StyleSheet.create({
|
|
|
101
149
|
paddingHorizontal: 14,
|
|
102
150
|
marginBottom: 18,
|
|
103
151
|
},
|
|
104
|
-
text: { flex: 1,
|
|
105
|
-
bold: {
|
|
106
|
-
link: { color: CATALOG_COLOR.warning,
|
|
152
|
+
text: { flex: 1, ...CATALOG_TEXT.body, color: CATALOG_COLOR.warning },
|
|
153
|
+
bold: { ...CATALOG_TEXT.bodyStrong },
|
|
154
|
+
link: { ...CATALOG_TEXT.bodyStrong, color: CATALOG_COLOR.warning, textDecorationLine: 'underline' },
|
|
107
155
|
close: { width: 32, height: 32, borderRadius: CATALOG_RADIUS.sm, alignItems: 'center', justifyContent: 'center' },
|
|
108
|
-
closeGlyph: {
|
|
156
|
+
closeGlyph: { ...CATALOG_TEXT.icon, color: CATALOG_COLOR.warning },
|
|
109
157
|
});
|
|
110
158
|
|
|
111
159
|
/**
|
|
@@ -174,9 +222,64 @@ export function CatalogShell<TId extends string>({
|
|
|
174
222
|
const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
|
|
175
223
|
const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
|
|
176
224
|
const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys())), [groups, sectionsById]);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
)
|
|
225
|
+
// Spec §5.3: every family page's own member ids/aliases, resolved to their page (and member, for
|
|
226
|
+
// anything other than the whole) — used both for an old/alias/member address (`resolveRoute`)
|
|
227
|
+
// and for the sidebar's member search (`memberSearchEntries` below).
|
|
228
|
+
const aliasMap = useMemo(() => aliasTargets(sections), [sections]);
|
|
229
|
+
// V2d item 3: every real catalog address a family member's `composedOf` could resolve to,
|
|
230
|
+
// outside its own family — every other page's own id, plus every member id/alias `aliasMap`
|
|
231
|
+
// already resolves (`FamilyPage` itself always adds its own family's member ids on top of this).
|
|
232
|
+
const knownAddressIds = useMemo(() => new Set<string>([...sectionsById.keys(), ...aliasMap.keys()]), [sectionsById, aliasMap]);
|
|
233
|
+
const memberIndex = useMemo(() => {
|
|
234
|
+
const map = new Map<string, string[]>();
|
|
235
|
+
for (const section of sections) {
|
|
236
|
+
if (!section.family) continue;
|
|
237
|
+
const ids: string[] = [];
|
|
238
|
+
for (const member of section.family.members) {
|
|
239
|
+
ids.push(member.id, ...(member.aliases ?? []));
|
|
240
|
+
}
|
|
241
|
+
map.set(section.id, ids);
|
|
242
|
+
}
|
|
243
|
+
return map;
|
|
244
|
+
}, [sections]);
|
|
245
|
+
const memberSearchEntries = useMemo(() => {
|
|
246
|
+
const entries: MemberSearchEntry<TId>[] = [];
|
|
247
|
+
for (const section of sections) {
|
|
248
|
+
if (!section.family) continue;
|
|
249
|
+
for (const member of section.family.members) {
|
|
250
|
+
if (member.id === section.family.whole) continue;
|
|
251
|
+
entries.push({ pageId: section.id, memberId: member.id, aliases: member.aliases, familyTitle: section.title ?? section.id });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return entries;
|
|
255
|
+
}, [sections]);
|
|
256
|
+
|
|
257
|
+
const routeFromHash = useCallback((hash: string) => resolveRoute(hash, order, aliasMap), [order, aliasMap]);
|
|
258
|
+
|
|
259
|
+
const [active, setActive] = useState<ShellPageId<TId> | undefined>(() => {
|
|
260
|
+
if (embedded) return initialPage ?? order[0];
|
|
261
|
+
if (!isWeb()) return order[0];
|
|
262
|
+
const route = routeFromHash(window.location.hash);
|
|
263
|
+
if (route.kind === 'empty') return order[0];
|
|
264
|
+
if (route.kind === 'update') return UPDATE_PAGE_ID;
|
|
265
|
+
if (route.kind === 'page') return route.id;
|
|
266
|
+
if (route.kind === 'member') return route.pageId;
|
|
267
|
+
return undefined; // notFound
|
|
268
|
+
});
|
|
269
|
+
const [pendingMemberId, setPendingMemberId] = useState<string | undefined>(() => {
|
|
270
|
+
if (embedded || !isWeb()) return undefined;
|
|
271
|
+
const route = routeFromHash(window.location.hash);
|
|
272
|
+
return route.kind === 'member' ? route.memberId : undefined;
|
|
273
|
+
});
|
|
274
|
+
// V2c item 11: bumped on every member activation, even a repeat one, so `FamilyPage`'s own
|
|
275
|
+
// one-shot jump (`shouldRunMemberJump`) counts a re-selected member address as fresh — a plain
|
|
276
|
+
// `pendingMemberId` state update bails out (no re-render) when the string value is unchanged.
|
|
277
|
+
const [pendingMemberNonce, setPendingMemberNonce] = useState(0);
|
|
278
|
+
const [notFoundRaw, setNotFoundRaw] = useState<string | undefined>(() => {
|
|
279
|
+
if (embedded || !isWeb()) return undefined;
|
|
280
|
+
const route = routeFromHash(window.location.hash);
|
|
281
|
+
return route.kind === 'notFound' ? route.raw : undefined;
|
|
282
|
+
});
|
|
180
283
|
|
|
181
284
|
const scrollRef = useRef<ScrollView>(null);
|
|
182
285
|
const headingRef = useRef<View>(null);
|
|
@@ -185,36 +288,78 @@ export function CatalogShell<TId extends string>({
|
|
|
185
288
|
const pushHistoryNext = useRef(false);
|
|
186
289
|
const focusHeadingNext = useRef(false);
|
|
187
290
|
|
|
188
|
-
const select = useCallback((id: ShellPageId<TId
|
|
291
|
+
const select = useCallback((id: ShellPageId<TId>, memberId?: string) => {
|
|
292
|
+
setNotFoundRaw(undefined);
|
|
189
293
|
if (id === activeRef.current) {
|
|
190
|
-
|
|
294
|
+
if (memberId) {
|
|
295
|
+
pushHistoryNext.current = true;
|
|
296
|
+
setPendingMemberId(memberId);
|
|
297
|
+
setPendingMemberNonce((n) => n + 1);
|
|
298
|
+
} else {
|
|
299
|
+
setPendingMemberId(undefined);
|
|
300
|
+
focusElement(headingRef.current);
|
|
301
|
+
}
|
|
191
302
|
return;
|
|
192
303
|
}
|
|
193
304
|
pushHistoryNext.current = true;
|
|
194
|
-
focusHeadingNext.current =
|
|
305
|
+
focusHeadingNext.current = !memberId;
|
|
306
|
+
setPendingMemberId(memberId);
|
|
307
|
+
if (memberId) setPendingMemberNonce((n) => n + 1);
|
|
195
308
|
setActive(id);
|
|
196
309
|
}, []);
|
|
197
310
|
|
|
198
|
-
// Back/forward
|
|
311
|
+
// Back/forward, manual fragment edits, and our own hash assignments below (which also fire
|
|
312
|
+
// `hashchange`) all funnel through here.
|
|
199
313
|
useEffect(() => {
|
|
200
314
|
if (!routesByUrl) return;
|
|
201
315
|
const onHashChange = () => {
|
|
202
|
-
const
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
316
|
+
const route = routeFromHash(window.location.hash);
|
|
317
|
+
if (route.kind === 'notFound') {
|
|
318
|
+
setNotFoundRaw(route.raw);
|
|
319
|
+
setPendingMemberId(undefined);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
setNotFoundRaw(undefined);
|
|
323
|
+
if (route.kind === 'empty') {
|
|
324
|
+
setPendingMemberId(undefined);
|
|
325
|
+
if (order[0] !== undefined && order[0] !== activeRef.current) {
|
|
326
|
+
focusHeadingNext.current = true;
|
|
327
|
+
setActive(order[0]);
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (route.kind === 'update') {
|
|
332
|
+
setPendingMemberId(undefined);
|
|
333
|
+
if (UPDATE_PAGE_ID !== activeRef.current) {
|
|
334
|
+
focusHeadingNext.current = true;
|
|
335
|
+
setActive(UPDATE_PAGE_ID);
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (route.kind === 'page') {
|
|
340
|
+
setPendingMemberId(undefined);
|
|
341
|
+
if (route.id !== activeRef.current) {
|
|
342
|
+
focusHeadingNext.current = true;
|
|
343
|
+
setActive(route.id);
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
// member: scroll/open/focus is the member itself (FamilyPage's own effect), never the page
|
|
348
|
+
// heading — even when this also switches the active page.
|
|
349
|
+
setPendingMemberId(route.memberId);
|
|
350
|
+
setPendingMemberNonce((n) => n + 1);
|
|
351
|
+
if (route.pageId !== activeRef.current) {
|
|
352
|
+
focusHeadingNext.current = false;
|
|
353
|
+
setActive(route.pageId);
|
|
210
354
|
}
|
|
211
355
|
};
|
|
212
356
|
window.addEventListener('hashchange', onHashChange);
|
|
213
357
|
return () => window.removeEventListener('hashchange', onHashChange);
|
|
214
|
-
}, [order, routesByUrl]);
|
|
358
|
+
}, [order, routesByUrl, routeFromHash]);
|
|
215
359
|
|
|
216
|
-
// After every page change: sync the fragment, return to the top
|
|
217
|
-
//
|
|
360
|
+
// After every page or member change: sync the fragment, return to the top (unless a member is
|
|
361
|
+
// addressed — FamilyPage scrolls to that instead), and move focus when the user navigated to a
|
|
362
|
+
// new page (never on first load, and never for a member address, which focuses the member).
|
|
218
363
|
useEffect(() => {
|
|
219
364
|
if (active === undefined) return;
|
|
220
365
|
const push = pushHistoryNext.current;
|
|
@@ -222,15 +367,17 @@ export function CatalogShell<TId extends string>({
|
|
|
222
367
|
pushHistoryNext.current = false;
|
|
223
368
|
focusHeadingNext.current = false;
|
|
224
369
|
if (routesByUrl) {
|
|
225
|
-
const expected = hashForId(active);
|
|
370
|
+
const expected = hashForId(pendingMemberId ?? active);
|
|
226
371
|
if (window.location.hash !== expected) {
|
|
227
372
|
if (push) window.location.hash = expected;
|
|
228
373
|
else window.history.replaceState(null, '', expected);
|
|
229
374
|
}
|
|
230
375
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
376
|
+
if (!pendingMemberId) {
|
|
377
|
+
resetMainScroll(scrollRef.current);
|
|
378
|
+
if (focus) focusElement(headingRef.current);
|
|
379
|
+
}
|
|
380
|
+
}, [active, pendingMemberId, routesByUrl]);
|
|
234
381
|
|
|
235
382
|
const activeDef = active !== undefined && active !== UPDATE_PAGE_ID ? sectionsById.get(active) : undefined;
|
|
236
383
|
|
|
@@ -284,9 +431,24 @@ export function CatalogShell<TId extends string>({
|
|
|
284
431
|
|
|
285
432
|
return (
|
|
286
433
|
<View style={styles.root}>
|
|
287
|
-
<CatalogSidebar
|
|
434
|
+
<CatalogSidebar
|
|
435
|
+
logo={appName}
|
|
436
|
+
logoImageSource={logoImageSource}
|
|
437
|
+
caption={title}
|
|
438
|
+
groups={groups}
|
|
439
|
+
labels={labels}
|
|
440
|
+
memberIndex={memberIndex}
|
|
441
|
+
memberSearchEntries={memberSearchEntries}
|
|
442
|
+
active={active}
|
|
443
|
+
onPress={select}
|
|
444
|
+
onSelectMember={(pageId, memberId) => select(pageId, memberId)}
|
|
445
|
+
footer={footer}
|
|
446
|
+
keepDocumentUrl={embedded}
|
|
447
|
+
/>
|
|
288
448
|
<ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
|
|
289
|
-
{
|
|
449
|
+
{notFoundRaw !== undefined ? (
|
|
450
|
+
<NotFoundPage raw={notFoundRaw} firstHref={order[0] !== undefined ? hashForId(order[0]) : undefined} onOpenFirst={() => order[0] !== undefined && select(order[0])} />
|
|
451
|
+
) : active === UPDATE_PAGE_ID ? (
|
|
290
452
|
<UpdatePanel
|
|
291
453
|
update={effectiveUpdate}
|
|
292
454
|
versionStatus={liveStatus}
|
|
@@ -295,6 +457,25 @@ export function CatalogShell<TId extends string>({
|
|
|
295
457
|
appName={appName}
|
|
296
458
|
headingRef={headingRef}
|
|
297
459
|
/>
|
|
460
|
+
) : activeDef?.family ? (
|
|
461
|
+
<FamilyPage
|
|
462
|
+
key={activeDef.id}
|
|
463
|
+
def={activeDef}
|
|
464
|
+
groupLabel={groupLabelFor(groups, activeDef.id)}
|
|
465
|
+
breadcrumbRoot={appName}
|
|
466
|
+
banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} keepDocumentUrl={embedded} /> : undefined}
|
|
467
|
+
pager={{
|
|
468
|
+
previousId: neighbors(order, activeDef.id).previous,
|
|
469
|
+
nextId: neighbors(order, activeDef.id).next,
|
|
470
|
+
onNavigate: select,
|
|
471
|
+
labelOf: (id) => labels.get(id) ?? id,
|
|
472
|
+
}}
|
|
473
|
+
headingRef={headingRef}
|
|
474
|
+
defaultPreviewWidths={defaultPreviewWidths}
|
|
475
|
+
focusMemberId={pendingMemberId}
|
|
476
|
+
focusNonce={pendingMemberNonce}
|
|
477
|
+
knownAddressIds={knownAddressIds}
|
|
478
|
+
/>
|
|
298
479
|
) : activeDef ? (
|
|
299
480
|
<SectionBlock
|
|
300
481
|
key={activeDef.id}
|
|
@@ -327,7 +508,7 @@ const styles = StyleSheet.create({
|
|
|
327
508
|
// A `BoundedOverlayViewport` child document's own root: no sidebar/chrome, just the one
|
|
328
509
|
// addressed node — sized by whatever that node itself renders, not the catalog's own layout.
|
|
329
510
|
overlayDocumentRoot: { flex: 1 },
|
|
330
|
-
overlayUnavailable: {
|
|
511
|
+
overlayUnavailable: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, padding: CATALOG_SPACE.lg },
|
|
331
512
|
main: { flex: 1 },
|
|
332
513
|
mainContent: {
|
|
333
514
|
width: '100%',
|
|
@@ -336,5 +517,5 @@ const styles = StyleSheet.create({
|
|
|
336
517
|
paddingHorizontal: CATALOG_LAYOUT.mainPaddingX,
|
|
337
518
|
paddingBottom: CATALOG_SPACE['3xl'],
|
|
338
519
|
},
|
|
339
|
-
empty: {
|
|
520
|
+
empty: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted },
|
|
340
521
|
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { View, Text, ScrollView, Pressable, StyleSheet, Image } from 'react-native';
|
|
3
3
|
import type { ImageSourcePropType, LayoutChangeEvent, NativeSyntheticEvent, ImageLoadEventData } from 'react-native';
|
|
4
|
-
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE,
|
|
4
|
+
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
|
|
5
5
|
import { logoLayout } from './logoLayout';
|
|
6
|
-
import { filterGroups, hashForId, UPDATE_PAGE_ID } from './catalogNavigation';
|
|
6
|
+
import { filterGroups, hashForId, searchResults, UPDATE_PAGE_ID, type MemberSearchEntry } from './catalogNavigation';
|
|
7
7
|
import type { NavGroup } from './types';
|
|
8
8
|
import { CatalogSearchInput } from './CatalogSearchInput';
|
|
9
9
|
|
|
@@ -52,6 +52,29 @@ function NavItem<TId extends string>({ id, label, active, onPress, keepDocumentU
|
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** One member search hit (spec §5.3 "Search"): `<memberId> — in <familyTitle>`, opening that
|
|
56
|
+
* member's address. Shares the plain nav-row visual `NavItem` uses ("Use the existing row
|
|
57
|
+
* styles"); `href` is the member's own address so Enter activates it the same way every other
|
|
58
|
+
* link in the sidebar does. */
|
|
59
|
+
function MemberResultItem({ label, familyTitle, href, onPress, keepDocumentUrl = false }: { label: string; familyTitle: string; href: string; onPress: () => void; keepDocumentUrl?: boolean }) {
|
|
60
|
+
const [hovered, setHovered] = useState(false);
|
|
61
|
+
const [focused, setFocused] = useState(false);
|
|
62
|
+
return (
|
|
63
|
+
<Pressable
|
|
64
|
+
onPress={pressHandler(onPress, keepDocumentUrl)}
|
|
65
|
+
onHoverIn={() => setHovered(true)}
|
|
66
|
+
onHoverOut={() => setHovered(false)}
|
|
67
|
+
onFocus={() => setFocused(true)}
|
|
68
|
+
onBlur={() => setFocused(false)}
|
|
69
|
+
accessibilityRole="link"
|
|
70
|
+
{...({ href } as Record<string, unknown>)}
|
|
71
|
+
style={({ pressed }) => [styles.item, (pressed || hovered) && styles.itemHover, focused && styles.focusRing]}
|
|
72
|
+
>
|
|
73
|
+
<Text style={styles.label}>{`${label} — in ${familyTitle}`}</Text>
|
|
74
|
+
</Pressable>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
function FooterLink({ label, kind, active, onPress, keepDocumentUrl = false }: { label: string; kind: 'update' | 'version'; active: boolean; onPress: () => void; keepDocumentUrl?: boolean }) {
|
|
56
79
|
const [hovered, setHovered] = useState(false);
|
|
57
80
|
const [focused, setFocused] = useState(false);
|
|
@@ -90,8 +113,11 @@ export function CatalogSidebar<TId extends string>({
|
|
|
90
113
|
caption,
|
|
91
114
|
groups,
|
|
92
115
|
labels,
|
|
116
|
+
memberIndex,
|
|
117
|
+
memberSearchEntries,
|
|
93
118
|
active,
|
|
94
119
|
onPress,
|
|
120
|
+
onSelectMember,
|
|
95
121
|
footer,
|
|
96
122
|
keepDocumentUrl = false,
|
|
97
123
|
}: {
|
|
@@ -107,8 +133,17 @@ export function CatalogSidebar<TId extends string>({
|
|
|
107
133
|
groups: NavGroup<TId>[];
|
|
108
134
|
/** Display titles by page id (`SectionDef.title`); pages without one show their id. */
|
|
109
135
|
labels?: ReadonlyMap<string, string>;
|
|
136
|
+
/** Every family page's own member ids/aliases, by page id (spec §5.3 "Search") — a page also
|
|
137
|
+
* matches the filter on any of these, not just its own id/title. */
|
|
138
|
+
memberIndex?: ReadonlyMap<string, readonly string[]>;
|
|
139
|
+
/** Every family member search candidate across the catalog (spec §5.3 "Search") — shown as its
|
|
140
|
+
* own "Members" group of hits, below the filtered pages, while the query is non-empty. */
|
|
141
|
+
memberSearchEntries?: readonly MemberSearchEntry<TId>[];
|
|
110
142
|
active: string | undefined;
|
|
111
143
|
onPress: (id: TId) => void;
|
|
144
|
+
/** Opens a member hit's address: activates its family page, then scrolls/opens/focuses the
|
|
145
|
+
* member itself (`CatalogShell`). */
|
|
146
|
+
onSelectMember?: (pageId: TId, memberId: string) => void;
|
|
112
147
|
/** The link below the nav list that opens the update page: the installed version (`'version'`),
|
|
113
148
|
* or the update notice with a dot (`'update'`, design §5's footer line). `active` is true while
|
|
114
149
|
* the update page itself is open — it gets the same active styling a selected nav item would. */
|
|
@@ -128,7 +163,8 @@ export function CatalogSidebar<TId extends string>({
|
|
|
128
163
|
const [loadedAspect, setLoadedAspect] = useState<number | undefined>(undefined);
|
|
129
164
|
const [logoFailed, setLogoFailed] = useState(false);
|
|
130
165
|
const [nameWrapped, setNameWrapped] = useState(false);
|
|
131
|
-
const filtered = filterGroups(groups, query, labels);
|
|
166
|
+
const filtered = filterGroups(groups, query, labels, memberIndex);
|
|
167
|
+
const memberHits = memberSearchEntries ? searchResults(memberSearchEntries, query) : [];
|
|
132
168
|
|
|
133
169
|
const logoAspect = staticAspect ?? loadedAspect;
|
|
134
170
|
const attemptingLogo = Boolean(logoImageSource) && !logoFailed;
|
|
@@ -153,8 +189,8 @@ export function CatalogSidebar<TId extends string>({
|
|
|
153
189
|
const onLogoError = () => setLogoFailed(true);
|
|
154
190
|
// Wrap detection for the "mark" row: the name Text's own measured height, not `onTextLayout`
|
|
155
191
|
// (react-native-web's `Text` doesn't implement it) and not a character-count heuristic (wraps at
|
|
156
|
-
// a different length per name). More than one line of `
|
|
157
|
-
const NAME_LINE_HEIGHT =
|
|
192
|
+
// a different length per name). More than one line of `CATALOG_TEXT.heading2` means it wrapped.
|
|
193
|
+
const NAME_LINE_HEIGHT = CATALOG_TEXT.heading2.lineHeight;
|
|
158
194
|
const onNameLayout = (e: LayoutChangeEvent) => {
|
|
159
195
|
setNameWrapped(e.nativeEvent.layout.height > NAME_LINE_HEIGHT * 1.5);
|
|
160
196
|
};
|
|
@@ -202,7 +238,10 @@ export function CatalogSidebar<TId extends string>({
|
|
|
202
238
|
|
|
203
239
|
<CatalogSearchInput value={query} onChangeText={setQuery} placeholder="Filter components…" />
|
|
204
240
|
|
|
205
|
-
{filtered.length === 0 && <Text style={styles.empty}>No matches</Text>}
|
|
241
|
+
{filtered.length === 0 && memberHits.length === 0 && <Text style={styles.empty}>No matches</Text>}
|
|
242
|
+
{/* V2c item 10: pages first, member hits below them — matching `memberSearchEntries`'s own
|
|
243
|
+
doc comment ("below the filtered pages") and the spec's "the sidebar lists family pages
|
|
244
|
+
only, never members" emphasis. */}
|
|
206
245
|
{filtered.map((group) => (
|
|
207
246
|
<View key={group.label}>
|
|
208
247
|
<Text style={styles.groupLabel}>{group.label}</Text>
|
|
@@ -211,6 +250,21 @@ export function CatalogSidebar<TId extends string>({
|
|
|
211
250
|
))}
|
|
212
251
|
</View>
|
|
213
252
|
))}
|
|
253
|
+
{memberHits.length > 0 && (
|
|
254
|
+
<View>
|
|
255
|
+
<Text style={styles.groupLabel}>Members</Text>
|
|
256
|
+
{memberHits.map((hit) => (
|
|
257
|
+
<MemberResultItem
|
|
258
|
+
key={`${hit.pageId}:${hit.memberId}`}
|
|
259
|
+
label={hit.label}
|
|
260
|
+
familyTitle={hit.familyTitle}
|
|
261
|
+
href={hashForId(hit.memberId)}
|
|
262
|
+
onPress={() => onSelectMember?.(hit.pageId, hit.memberId)}
|
|
263
|
+
keepDocumentUrl={keepDocumentUrl}
|
|
264
|
+
/>
|
|
265
|
+
))}
|
|
266
|
+
</View>
|
|
267
|
+
)}
|
|
214
268
|
</ScrollView>
|
|
215
269
|
{footer && (
|
|
216
270
|
<View style={styles.footerContainer}>
|
|
@@ -235,8 +289,8 @@ const styles = StyleSheet.create({
|
|
|
235
289
|
paddingBottom: CATALOG_LAYOUT.sidebarPaddingTop,
|
|
236
290
|
},
|
|
237
291
|
header: { marginBottom: 20 },
|
|
238
|
-
logo: {
|
|
239
|
-
subtitle: {
|
|
292
|
+
logo: { ...CATALOG_TEXT.heading2, color: CATALOG_COLOR.text },
|
|
293
|
+
subtitle: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, marginTop: 2 },
|
|
240
294
|
subtitleAfterWordmark: { marginTop: CATALOG_LAYOUT.logoCaptionGap },
|
|
241
295
|
mark: { width: CATALOG_LAYOUT.logoMark, height: CATALOG_LAYOUT.logoMark },
|
|
242
296
|
wordmark: { height: CATALOG_LAYOUT.logoWordmarkHeight, alignSelf: 'flex-start' },
|
|
@@ -244,13 +298,12 @@ const styles = StyleSheet.create({
|
|
|
244
298
|
inlineHeader: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md },
|
|
245
299
|
inlineHeaderTop: { alignItems: 'flex-start' },
|
|
246
300
|
inlineText: { flex: 1, minWidth: 0 },
|
|
247
|
-
empty: {
|
|
301
|
+
empty: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, paddingHorizontal: CATALOG_LAYOUT.navItemPaddingX, paddingVertical: 8 },
|
|
248
302
|
footerContainer: {
|
|
249
303
|
borderTopWidth: 1,
|
|
250
304
|
borderTopColor: CATALOG_COLOR.borderHairline,
|
|
251
305
|
paddingHorizontal: CATALOG_LAYOUT.sidebarPaddingX,
|
|
252
|
-
|
|
253
|
-
paddingBottom: 14,
|
|
306
|
+
paddingVertical: CATALOG_SPACE.md,
|
|
254
307
|
},
|
|
255
308
|
footerLink: {
|
|
256
309
|
flexDirection: 'row',
|
|
@@ -263,13 +316,10 @@ const styles = StyleSheet.create({
|
|
|
263
316
|
footerLinkHover: { backgroundColor: CATALOG_COLOR.surfacePressed },
|
|
264
317
|
footerLinkActive: { backgroundColor: CATALOG_COLOR.accentSubtle },
|
|
265
318
|
footerDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: CATALOG_COLOR.accent, flexShrink: 0 },
|
|
266
|
-
footerLabel: {
|
|
267
|
-
footerLabelActive: { color: CATALOG_COLOR.accent
|
|
319
|
+
footerLabel: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted },
|
|
320
|
+
footerLabelActive: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.accent },
|
|
268
321
|
groupLabel: {
|
|
269
|
-
|
|
270
|
-
fontWeight: '800',
|
|
271
|
-
letterSpacing: 1,
|
|
272
|
-
textTransform: 'uppercase',
|
|
322
|
+
...CATALOG_TEXT.microLabel,
|
|
273
323
|
color: CATALOG_COLOR.text,
|
|
274
324
|
marginTop: 22,
|
|
275
325
|
marginBottom: 7,
|
|
@@ -285,6 +335,6 @@ const styles = StyleSheet.create({
|
|
|
285
335
|
itemHover: { backgroundColor: CATALOG_COLOR.surfacePressed },
|
|
286
336
|
itemActive: { backgroundColor: CATALOG_COLOR.accentSubtle },
|
|
287
337
|
focusRing: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing },
|
|
288
|
-
label: {
|
|
289
|
-
labelActive: { color: CATALOG_COLOR.accent
|
|
338
|
+
label: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted },
|
|
339
|
+
labelActive: { ...CATALOG_TEXT.bodyStrong, color: CATALOG_COLOR.accent },
|
|
290
340
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { View, Text, ScrollView, StyleSheet } from 'react-native';
|
|
3
|
-
import { CATALOG_COLOR, CATALOG_RADIUS,
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TEXT } from './tokens';
|
|
4
4
|
import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, axisItems, cellKey, gridMinWidth, indexCells, validateComparison } from './comparison';
|
|
5
5
|
import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
6
|
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
@@ -108,7 +108,7 @@ const styles = StyleSheet.create({
|
|
|
108
108
|
headerCell: { backgroundColor: CATALOG_COLOR.surfaceMuted, justifyContent: 'center' },
|
|
109
109
|
bodyRow: { minHeight: MATRIX_LAYOUT.rowMinHeight },
|
|
110
110
|
specimenCell: { alignItems: 'center', justifyContent: 'center' },
|
|
111
|
-
headerText: {
|
|
112
|
-
rowHeaderText: {
|
|
113
|
-
unavailable: {
|
|
111
|
+
headerText: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
|
|
112
|
+
rowHeaderText: { ...CATALOG_TEXT.bodyStrong, color: CATALOG_COLOR.text },
|
|
113
|
+
unavailable: { ...CATALOG_TEXT.caption, textAlign: 'center', color: CATALOG_COLOR.textMuted },
|
|
114
114
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { View, Text, ScrollView, StyleSheet } from 'react-native';
|
|
3
|
-
import { CATALOG_COLOR, CATALOG_RADIUS,
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TEXT } from './tokens';
|
|
4
4
|
import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, type ListGroup } from './comparison';
|
|
5
5
|
import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
6
|
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
@@ -90,7 +90,7 @@ const styles = StyleSheet.create({
|
|
|
90
90
|
borderRightWidth: 1,
|
|
91
91
|
borderRightColor: CATALOG_COLOR.border,
|
|
92
92
|
},
|
|
93
|
-
rowHeaderText: {
|
|
93
|
+
rowHeaderText: { ...CATALOG_TEXT.bodyStrong, color: CATALOG_COLOR.text },
|
|
94
94
|
cells: { flex: 1, flexDirection: 'row' },
|
|
95
95
|
cell: { flex: 1, borderRightWidth: 1, borderRightColor: CATALOG_COLOR.border },
|
|
96
96
|
lastColumn: { borderRightWidth: 0 },
|
|
@@ -100,7 +100,7 @@ const styles = StyleSheet.create({
|
|
|
100
100
|
borderBottomWidth: 1,
|
|
101
101
|
borderBottomColor: CATALOG_COLOR.border,
|
|
102
102
|
},
|
|
103
|
-
captionText: {
|
|
103
|
+
captionText: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
|
|
104
104
|
specimen: {
|
|
105
105
|
flexGrow: 1,
|
|
106
106
|
minHeight: MATRIX_LAYOUT.rowMinHeight,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
2
|
import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
|
|
3
|
-
import { CATALOG_COLOR, CATALOG_RADIUS,
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TEXT } from './tokens';
|
|
4
4
|
import { MATRIX_LAYOUT, listGeometry, type ListItem } from './comparison';
|
|
5
5
|
import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
6
|
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
@@ -107,7 +107,7 @@ const styles = StyleSheet.create({
|
|
|
107
107
|
borderBottomWidth: 1,
|
|
108
108
|
borderBottomColor: CATALOG_COLOR.border,
|
|
109
109
|
},
|
|
110
|
-
captionText: {
|
|
110
|
+
captionText: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
|
|
111
111
|
specimen: {
|
|
112
112
|
flexGrow: 1,
|
|
113
113
|
minHeight: MATRIX_LAYOUT.rowMinHeight,
|