@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.
Files changed (37) hide show
  1. package/README.md +249 -3
  2. package/config/index.ts +3 -0
  3. package/dist/cli/config.js +9 -1
  4. package/dist/cli/doctor.js +452 -19
  5. package/dist/cli/explain.js +26 -0
  6. package/dist/cli/staticPage.js +99 -10
  7. package/dist/cli/tokenDefinition.js +125 -0
  8. package/dist/native/catalog/catalogNavigation.js +27 -11
  9. package/dist/native/catalog/pageApi.js +557 -26
  10. package/dist/native/catalog/tokenDefinition.js +205 -0
  11. package/native/catalog/CatalogSearchInput.tsx +18 -3
  12. package/native/catalog/CatalogShell.tsx +318 -46
  13. package/native/catalog/CatalogSidebar.tsx +37 -1
  14. package/native/catalog/FamilyPage.tsx +567 -137
  15. package/native/catalog/GroupPage.tsx +230 -0
  16. package/native/catalog/JumpRow.tsx +81 -0
  17. package/native/catalog/ReferenceDetails.tsx +144 -16
  18. package/native/catalog/SectionBlock.tsx +49 -5
  19. package/native/catalog/SourceReferencePicker.tsx +180 -0
  20. package/native/catalog/StructuredTokenPage.tsx +592 -0
  21. package/native/catalog/Swatch.tsx +29 -2
  22. package/native/catalog/TokenLayouts.tsx +30 -9
  23. package/native/catalog/catalogNavigation.ts +30 -10
  24. package/native/catalog/catalogReferences.ts +212 -0
  25. package/native/catalog/familyModel.ts +10 -4
  26. package/native/catalog/index.ts +22 -0
  27. package/native/catalog/pageApi.ts +598 -23
  28. package/native/catalog/tokenDefinition.ts +430 -0
  29. package/native/catalog/types.ts +77 -5
  30. package/package.json +1 -1
  31. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -0
  32. package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -0
  33. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +1 -0
  34. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +1 -0
  35. package/starter-kit/components/Divider/Divider.catalog.tsx +1 -0
  36. package/starter-kit/components/Pill/Pill.catalog.tsx +1 -0
  37. package/starter-kit/components/TopNav/TopNav.catalog.tsx +1 -0
@@ -5,10 +5,12 @@ import { ComparisonGrid } from './ComparisonGrid';
5
5
  import { ComparisonGroups } from './ComparisonGroups';
6
6
  import { ComparisonList } from './ComparisonList';
7
7
  import { ReferenceDetails } from './ReferenceDetails';
8
+ import type { ReferenceNavigation } from './catalogReferences';
8
9
  import { SpecimenSurface } from './SpecimenSurface';
9
10
  import { SpecimenAddressProvider } from './SpecimenAddress';
10
11
  import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
11
12
  import { TokenSections } from './TokenLayouts';
13
+ import { TokenDefinitionBlock } from './StructuredTokenPage';
12
14
  import { MATRIX_LAYOUT, PREVIEW_CARD_CHROME, blockMinWidth, choosePlacement, presentationBlocks, type PlacementBlock, type PresentationBlock } from './comparison';
13
15
  import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
14
16
 
@@ -164,7 +166,18 @@ function TablePreview({ render, widths, surface }: { render: () => React.ReactNo
164
166
  );
165
167
  }
166
168
 
167
- function BlockContent<TId extends string>({ block, def, surface }: { block: PresentationBlock; def: SectionDef<TId>; surface: SpecimenSurfaceKind }) {
169
+ function BlockContent<TId extends string>({
170
+ block, def, surface, focusSectionId, focusNonce,
171
+ }: {
172
+ block: PresentationBlock;
173
+ def: SectionDef<TId>;
174
+ surface: SpecimenSurfaceKind;
175
+ /** #64 B5: a tokenDefinition page's own section to scroll into view on arrival (a section-alias
176
+ * route) — only ever consumed by the 'tokenSections' case below; every other block kind ignores
177
+ * it. */
178
+ focusSectionId?: string;
179
+ focusNonce?: number;
180
+ }) {
168
181
  switch (block.kind) {
169
182
  case 'grid':
170
183
  return <ComparisonGrid def={block.comparison} size={block.size} sectionId={def.id} surface={surface} />;
@@ -182,8 +195,18 @@ function BlockContent<TId extends string>({ block, def, surface }: { block: Pres
182
195
  <Preview render={def.render} widths={block.widths} surface={def.tokenGallery ? undefined : surface} layout={def.previewLayout === 'table' ? 'table' : undefined} />
183
196
  </SpecimenAddressProvider>
184
197
  ) : null;
185
- case 'tokenSections':
198
+ case 'tokenSections': {
199
+ // #61: a page's own `tokenDefinition` (when set) carries the real entry data —
200
+ // `buildCatalogSections` only ever derives `block.sections` from it as placeholder geometry
201
+ // (`render: () => null`), so the real rendering happens here instead, at this file's own
202
+ // existing JSX boundary. #64 B2/B5: `TokenDefinitionBlock` additionally renders the jump row
203
+ // and consumes a pending section arrival; a legacy `tokenSections` page (no `tokenDefinition`)
204
+ // keeps the plain `TokenSections` rendering, unchanged.
205
+ if (def.tokenDefinition) {
206
+ return <TokenDefinitionBlock definition={def.tokenDefinition} columns={block.columns} label={def.id} focusSectionId={focusSectionId} focusNonce={focusNonce} />;
207
+ }
186
208
  return <TokenSections sections={block.sections} columns={block.columns} label={def.id} />;
209
+ }
187
210
  default:
188
211
  return (
189
212
  <View style={styles.previewCard}>
@@ -229,6 +252,8 @@ export function Blocks<TId extends string>({
229
252
  surface,
230
253
  showLabels = true,
231
254
  trimOuterMargin = false,
255
+ focusSectionId,
256
+ focusNonce,
232
257
  }: {
233
258
  blocks: PresentationBlock[];
234
259
  def: SectionDef<TId>;
@@ -242,6 +267,10 @@ export function Blocks<TId extends string>({
242
267
  * two never double-stack. A page's own `SectionBlock` keeps the margin (the default,
243
268
  * unchanged). */
244
269
  trimOuterMargin?: boolean;
270
+ /** #64 B5: forwarded to `BlockContent`'s own 'tokenSections' case only — every other block kind
271
+ * ignores it. */
272
+ focusSectionId?: string;
273
+ focusNonce?: number;
245
274
  }) {
246
275
  const [available, setAvailable] = useState(0);
247
276
  const [heights, setHeights] = useState<number[]>([]);
@@ -297,7 +326,7 @@ export function Blocks<TId extends string>({
297
326
  carrying one shows its (already informative) title on a family page, which otherwise
298
327
  hides every generic block label via `showLabels={false}`. */}
299
328
  {(showLabels && block.title) || ('axis' in block && block.axis) ? <Text style={styles.blockLabel}>{('axis' in block && block.axis) || block.title}</Text> : null}
300
- <BlockContent block={block} def={def} surface={surface} />
329
+ <BlockContent block={block} def={def} surface={surface} focusSectionId={focusSectionId} focusNonce={focusNonce} />
301
330
  </View>
302
331
  ))}
303
332
  </View>
@@ -319,6 +348,9 @@ export function SectionBlock<TId extends string>({
319
348
  headingRef,
320
349
  headingLevel = 1,
321
350
  defaultPreviewWidths,
351
+ navigation,
352
+ focusSectionId,
353
+ focusNonce,
322
354
  }: {
323
355
  def: SectionDef<TId>;
324
356
  groupLabel?: string;
@@ -331,6 +363,18 @@ export function SectionBlock<TId extends string>({
331
363
  headingLevel?: 1 | 2;
332
364
  /** Preview widths for pages without `previewWidths` (CatalogShell passes its catalog default). */
333
365
  defaultPreviewWidths?: PreviewWidths;
366
+ /** Task 2B2: `CatalogShell`'s own shared reference navigation, passed straight through to
367
+ * `ReferenceDetails` so a resolvable Composition name becomes a real canonical link. Omitted for
368
+ * a standalone-mounted `SectionBlock` (this framework's own catalog demo included), which then
369
+ * keeps its prior, unlinked rendering — see `ReferenceDetails`'s own doc comment. */
370
+ navigation?: ReferenceNavigation<TId>;
371
+ /** #64 B5: a tokenDefinition page's own section to scroll into view on arrival — set by
372
+ * `CatalogShell` from `resolveRoute`'s own `sectionId` (a section-alias route). Omitted for a
373
+ * standalone-mounted `SectionBlock`, which then never auto-scrolls to a section. */
374
+ focusSectionId?: string;
375
+ /** Bumped by `CatalogShell` on every activation, even a repeat one — the same
376
+ * `focusMemberId`/`focusNonce` convention `FamilyPage` already uses. */
377
+ focusNonce?: number;
334
378
  }) {
335
379
  checkCompleteness(def);
336
380
  // Memoized so Blocks' placement effect runs on real changes, not on every render.
@@ -361,9 +405,9 @@ export function SectionBlock<TId extends string>({
361
405
  {pager && <SectionPagerRow pager={pager} />}
362
406
  </View>
363
407
 
364
- {blocks.length > 0 && <Blocks blocks={blocks} def={def} surface={surface} />}
408
+ {blocks.length > 0 && <Blocks blocks={blocks} def={def} surface={surface} focusSectionId={focusSectionId} focusNonce={focusNonce} />}
365
409
 
366
- <ReferenceDetails def={def} />
410
+ <ReferenceDetails def={def} navigation={navigation} />
367
411
  </View>
368
412
  );
369
413
  }
@@ -0,0 +1,180 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react';
2
+ import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View, type TextStyle } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
4
+ import { CatalogSearchInput } from './CatalogSearchInput';
5
+ import { CatalogButton } from './CatalogButton';
6
+ import { hashForId, sortIds } from './catalogNavigation';
7
+ import { searchReferenceIndex, type ReferenceEntry } from './catalogReferences';
8
+
9
+ // react-native-web reads `aria-level`; React Native's own prop types do not declare it (same
10
+ // convention `ReferenceDetails.tsx`'s own `HEADING_LEVEL_2` already uses).
11
+ const HEADING_LEVEL_2 = { 'aria-level': 2 } as Record<string, unknown>;
12
+
13
+ function entryKey<TId extends string>(entry: ReferenceEntry<TId>): string {
14
+ return entry.memberId ?? entry.pageId;
15
+ }
16
+
17
+ /** A–Z by displayed title, deterministic id ties — the catalog's own canonical comparator
18
+ * (`catalogNavigation.ts`'s `sortIds`), never a second, bespoke sort here. `entryKey` is safe as
19
+ * `sortIds`'s own id: `validateFamily` already enforces that every page id, member id, and alias
20
+ * shares one catalog-wide-unique namespace. */
21
+ function sortedEntries<TId extends string>(entries: readonly ReferenceEntry<TId>[]): ReferenceEntry<TId>[] {
22
+ const byKey = new Map(entries.map((entry) => [entryKey(entry), entry]));
23
+ const labels = new Map(entries.map((entry) => [entryKey(entry), entry.title]));
24
+ return sortIds([...byKey.keys()], labels).map((key) => byKey.get(key)!);
25
+ }
26
+
27
+ /** A modified click (open in a new tab/window) or a non-primary button must reach the browser's
28
+ * own anchor handling untouched — only a plain primary click/Enter/tap becomes a selection. */
29
+ function isModifiedPress(event: unknown): boolean {
30
+ const e = event as { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; button?: number } | null | undefined;
31
+ return Boolean(e?.metaKey || e?.ctrlKey || e?.shiftKey || (typeof e?.button === 'number' && e.button !== 0));
32
+ }
33
+
34
+ function ResultRow<TId extends string>({
35
+ entry, label, onSelect,
36
+ }: {
37
+ entry: ReferenceEntry<TId>;
38
+ label: string;
39
+ onSelect: (entry: ReferenceEntry<TId>) => void;
40
+ }) {
41
+ const [hovered, setHovered] = useState(false);
42
+ const [focused, setFocused] = useState(false);
43
+ return (
44
+ <Pressable
45
+ onPress={(event) => {
46
+ if (isModifiedPress(event)) return; // let the browser open it in a new tab/window
47
+ (event as { preventDefault?: () => void } | undefined)?.preventDefault?.();
48
+ onSelect(entry);
49
+ }}
50
+ onHoverIn={() => setHovered(true)}
51
+ onHoverOut={() => setHovered(false)}
52
+ onFocus={() => setFocused(true)}
53
+ onBlur={() => setFocused(false)}
54
+ accessibilityRole="link"
55
+ // A real `<a href="#Id">` on web — Enter activates it, and copy/modified-click work exactly
56
+ // as every other catalog link already does (`CatalogSidebar.tsx`'s own `NavItem`).
57
+ {...({ href: hashForId(entryKey(entry)) } as Record<string, unknown>)}
58
+ style={({ pressed }) => [styles.row, (pressed || hovered) && styles.rowHover, focused && styles.focusRing]}
59
+ >
60
+ <Text style={styles.rowTitle}>{label}</Text>
61
+ <Text style={styles.rowMeta}>{entry.component ? `${entry.component} · ${entry.source ?? entry.path}` : (entry.source ?? entry.path)}</Text>
62
+ {entry.source && entry.source !== entry.path ? <Text style={styles.rowMeta}>{`Implementation: ${entry.path}`}</Text> : null}
63
+ </Pressable>
64
+ );
65
+ }
66
+
67
+ export interface SourceReferencePickerProps<TId extends string = string> {
68
+ /** Controlled — the parent (its Task 2B `CatalogShell` caller) owns open/close state. */
69
+ visible: boolean;
70
+ /** The catalog-wide index to search (`catalogReferences.ts`'s own `buildReferenceIndex`). */
71
+ entries: readonly ReferenceEntry<TId>[];
72
+ /** Escape, the native request-close (Android back / RNW's own Escape handling), or the explicit
73
+ * Close control — dismissal with no selection. */
74
+ onDismiss: () => void;
75
+ /** A plain click/Enter/tap on one result. This leaf never closes itself or navigates on
76
+ * selection — the parent owns what happens next (closing the picker, updating the route, and
77
+ * focusing the destination); see this file's own module doc comment. */
78
+ onSelect: (entry: ReferenceEntry<TId>) => void;
79
+ /** Durable, visibly associated label for the search field (`CatalogSearchInput`'s own
80
+ * `accessibilityLabel`). @default 'Search source references' */
81
+ searchLabel?: string;
82
+ }
83
+
84
+ /**
85
+ * #60's catalog-wide Source reference picker (plan Task 2, "the accepted A access model"): a
86
+ * controlled modal searching every registered page/member address (`ReferenceEntry[]`), never a
87
+ * page-local member search. Built entirely from existing catalog primitives (`Modal`,
88
+ * `CatalogSearchInput`, `CatalogButton`, the same link/row conventions `CatalogSidebar.tsx`
89
+ * already uses) and catalog tokens — no new dependency, theme, or layout system.
90
+ *
91
+ * This component owns none of the application's navigation/history: `onSelect` only reports a
92
+ * choice, and closing (`visible`) stays fully under the parent's control, so selecting never
93
+ * races this component's own close against whatever focus move the parent makes afterwards. Its
94
+ * real `CatalogShell` caller, wiring `visible`/`onSelect` to an actual route change, is Task 2B —
95
+ * this file and its own self-catalog demo are available infrastructure, not yet adopted product
96
+ * behavior. Keyboard focus trapping and restoring the initiating control on cancellation reuse
97
+ * `Modal`'s own built-in behavior (react-native-web's `ModalFocusTrap`) rather than a second,
98
+ * hand-built dialog engine.
99
+ */
100
+ export function SourceReferencePicker<TId extends string = string>({
101
+ visible, entries, onDismiss, onSelect, searchLabel = 'Search source references',
102
+ }: SourceReferencePickerProps<TId>) {
103
+ const [query, setQuery] = useState('');
104
+ const searchInputRef = useRef<TextInput>(null);
105
+ const focusSearch = useCallback(() => searchInputRef.current?.focus(), []);
106
+ // Let Modal capture the trigger before moving focus inside. Mount-time autoFocus would
107
+ // instead make its focus-restoration effect remember the search field that is being removed.
108
+ // A member row's own title is just its id (`buildReferenceIndex`'s own convention) — this looks
109
+ // up the family's displayed title for the "<id> — in <family title>" line below, the same
110
+ // wording `CatalogSidebar.tsx`'s own `MemberResultItem` already uses for a member search hit.
111
+ const pageTitleByPageId = useMemo(
112
+ () => new Map(entries.filter((entry) => entry.memberId === undefined).map((entry) => [entry.pageId, entry.title])),
113
+ [entries],
114
+ );
115
+ const trimmedQuery = query.trim();
116
+ const results = useMemo(
117
+ () => sortedEntries(trimmedQuery ? searchReferenceIndex(entries, trimmedQuery) : entries),
118
+ [entries, trimmedQuery],
119
+ );
120
+ return (
121
+ <Modal visible={visible} transparent animationType="none" accessibilityLabel="Source reference" onRequestClose={onDismiss} onShow={focusSearch}>
122
+ <View style={styles.scrim}>
123
+ <View style={styles.dialog}>
124
+ <View style={styles.headerRow}>
125
+ <Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Source reference</Text>
126
+ <CatalogButton label="Close" variant="secondary" accessibilityLabel="Close source reference picker" onPress={onDismiss} />
127
+ </View>
128
+ <Text style={styles.searchLabel}>{searchLabel}</Text>
129
+ <CatalogSearchInput
130
+ value={query}
131
+ onChangeText={setQuery}
132
+ placeholder="Search by name, component, alias, or path"
133
+ accessibilityLabel={searchLabel}
134
+ inputRef={searchInputRef}
135
+ />
136
+ <ScrollView style={styles.list} accessibilityRole="list">
137
+ {entries.length === 0 ? (
138
+ <Text style={styles.empty}>No source references are registered.</Text>
139
+ ) : results.length === 0 ? (
140
+ <Text style={styles.empty}>{`No matches for "${trimmedQuery}".`}</Text>
141
+ ) : (
142
+ results.map((entry) => {
143
+ const label = entry.memberId
144
+ ? `${entry.title} — in ${pageTitleByPageId.get(entry.pageId) ?? entry.pageId}`
145
+ : entry.title;
146
+ return <View key={entryKey(entry)} role="listitem"><ResultRow entry={entry} label={label} onSelect={onSelect} /></View>;
147
+ })
148
+ )}
149
+ </ScrollView>
150
+ </View>
151
+ </View>
152
+ </Modal>
153
+ );
154
+ }
155
+
156
+ const styles = StyleSheet.create({
157
+ // No existing catalog token covers a scrim or a modal dialog's own bounds — both are
158
+ // component-local, the same way `OverlayDemo.catalog.tsx`'s own demo scrim already is.
159
+ scrim: {
160
+ flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', alignItems: 'center', justifyContent: 'center', padding: CATALOG_SPACE.xl,
161
+ },
162
+ dialog: {
163
+ width: '100%', maxWidth: 480, maxHeight: '80%',
164
+ backgroundColor: CATALOG_COLOR.surface, borderRadius: CATALOG_RADIUS.card,
165
+ padding: CATALOG_LAYOUT.panelPadding, gap: CATALOG_SPACE.sm,
166
+ },
167
+ headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: CATALOG_SPACE.md },
168
+ heading: { ...CATALOG_TEXT.label, color: CATALOG_COLOR.text },
169
+ searchLabel: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
170
+ list: { marginTop: CATALOG_SPACE.xs },
171
+ row: { paddingVertical: CATALOG_SPACE.sm, paddingHorizontal: CATALOG_SPACE.sm, borderRadius: CATALOG_RADIUS.control, gap: 2, minHeight: CATALOG_LAYOUT.controlSize },
172
+ rowHover: { backgroundColor: CATALOG_COLOR.surfacePressed },
173
+ focusRing: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing },
174
+ rowTitle: { ...CATALOG_TEXT.bodyStrong, color: CATALOG_COLOR.text },
175
+ // A real source path never truncates and may have no spaces to wrap at — `wordBreak` isn't in
176
+ // the installed (React Native, not RNW) `TextStyle` type, the same cast `ReferenceDetails.tsx`'s
177
+ // own `mono` style already needs.
178
+ rowMeta: { ...CATALOG_TEXT.codeSmall, color: CATALOG_COLOR.textMuted, wordBreak: 'break-word' } as TextStyle,
179
+ empty: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, paddingVertical: CATALOG_SPACE.md },
180
+ });