@krapwoo/ds-viewer 0.4.5 → 0.4.7
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 +111 -18
- package/dist/cli/dev.js +31 -6
- package/dist/cli/doctor.js +137 -0
- package/dist/cli/endpoint.js +76 -1
- package/dist/cli/explain.js +44 -6
- package/dist/cli/main.js +4 -2
- package/dist/cli/props.js +80 -1
- package/dist/cli/staticPage.js +228 -6
- package/dist/cli/updateCheck.js +174 -43
- package/dist/cli/workspace.js +2 -1
- package/dist/native/catalog/catalogNavigation.js +11 -0
- package/dist/native/catalog/comparison.js +29 -4
- package/native/catalog/BoundedOverlayViewport.tsx +112 -0
- package/native/catalog/CatalogShell.tsx +69 -12
- package/native/catalog/CatalogSidebar.tsx +8 -7
- package/native/catalog/ComparisonGrid.tsx +2 -2
- package/native/catalog/ComparisonGroups.tsx +2 -2
- package/native/catalog/ComparisonList.tsx +2 -2
- package/native/catalog/PhoneFrame.tsx +6 -0
- package/native/catalog/ReferenceDetails.tsx +75 -20
- package/native/catalog/SectionBlock.tsx +66 -4
- package/native/catalog/SpecimenSurface.tsx +18 -10
- package/native/catalog/UpdatePanel.tsx +161 -5
- package/native/catalog/catalogNavigation.ts +14 -1
- package/native/catalog/comparison.ts +39 -5
- package/native/catalog/index.ts +4 -0
- package/native/catalog/overlayViewport.ts +156 -0
- package/native/catalog/pageApi.ts +6 -0
- package/native/catalog/specimenSurfaceStyle.ts +70 -1
- package/native/catalog/types.ts +62 -12
- package/native/catalog/updatePanelState.ts +72 -0
- package/package.json +1 -1
- package/starter-kit/components/Banner/Banner.catalog.tsx +3 -0
- package/starter-kit/components/Button/Button.catalog.tsx +3 -0
- package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +6 -0
- package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +5 -0
- package/starter-kit/components/InputField/InputField.catalog.tsx +3 -0
- package/starter-kit/components/List/List.catalog.tsx +3 -0
- package/starter-kit/components/Pill/Pill.catalog.tsx +3 -0
- package/starter-kit/components/PillRow/PillRow.catalog.tsx +3 -0
- package/starter-kit/components/SearchField/SearchField.catalog.tsx +4 -0
- package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +3 -0
- package/starter-kit/components/TextArea/TextArea.catalog.tsx +3 -0
- package/starter-kit/components/Toast/Toast.catalog.tsx +7 -0
|
@@ -129,3 +129,14 @@ export function footerLabel(update) {
|
|
|
129
129
|
return undefined;
|
|
130
130
|
return `Update available · ${update.latest}`;
|
|
131
131
|
}
|
|
132
|
+
/** The sidebar's footer line: the blue "Update available" notice when a newer version is known
|
|
133
|
+
* (from startup or **Check now**), else the installed version, quietly. `undefined` only for a
|
|
134
|
+
* workspace generated before version status existed, which keeps the old notice-only footer. */
|
|
135
|
+
export function footerLink(update, status) {
|
|
136
|
+
const notice = footerLabel(update) ?? footerLabel(status?.update);
|
|
137
|
+
if (notice)
|
|
138
|
+
return { label: notice, kind: 'update' };
|
|
139
|
+
if (status)
|
|
140
|
+
return { label: `DS Viewer ${status.current}`, kind: 'version' };
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
@@ -15,6 +15,12 @@ export const PLACEMENT_MIN_SAVING = 120;
|
|
|
15
15
|
export const PROPS_MIN_COLUMN_WIDTH = 360;
|
|
16
16
|
/** Gap between the two Props columns (CATALOG_SPACE['2xl']). */
|
|
17
17
|
export const PROPS_COLUMN_GAP = 32;
|
|
18
|
+
/** A reference column (Guidance, Quick reference, or Composition) is never narrower than this;
|
|
19
|
+
* below it, `referenceColumns` drops to fewer columns. */
|
|
20
|
+
export const REFERENCE_MIN_COLUMN_WIDTH = 280;
|
|
21
|
+
/** Gap between reference columns (CATALOG_SPACE['2xl'], the same token the card's columns already
|
|
22
|
+
* use). */
|
|
23
|
+
export const REFERENCE_COLUMN_GAP = 32;
|
|
18
24
|
/** Smallest column or cell width per specimen size. Wide specimens are fixed at phone width. */
|
|
19
25
|
export const COLUMN_MIN_WIDTH = { compact: 160, regular: 240, wide: 402 };
|
|
20
26
|
/** `Array.isArray`'s built-in type guard narrows to a mutable `any[]`, which a `readonly T[]`
|
|
@@ -184,7 +190,9 @@ export function slotSize(def, slot) {
|
|
|
184
190
|
return def.specimenSize ?? (slot.itemsFill ? 'wide' : 'regular');
|
|
185
191
|
}
|
|
186
192
|
function slotItems(slot) {
|
|
187
|
-
|
|
193
|
+
// Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
|
|
194
|
+
// `itemsFill: true`, which `false || slot.itemsFill` would silently discard.
|
|
195
|
+
return slot.items.map((item) => ({ key: item.key, label: item.name, node: item.node, fill: item.fill ?? slot.itemsFill, surface: item.surface, align: item.align }));
|
|
188
196
|
}
|
|
189
197
|
/** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
|
|
190
198
|
function previewWidths(requested, fallback) {
|
|
@@ -198,12 +206,14 @@ function groupStates(variants, states) {
|
|
|
198
206
|
const variantKeys = new Set(variants.items.map((v) => v.key));
|
|
199
207
|
if (!states.items.some((s) => s.group !== undefined && variantKeys.has(s.group)))
|
|
200
208
|
return undefined;
|
|
201
|
-
|
|
209
|
+
// Nullish, not `||` — same reasoning as `slotItems`: an explicit `fill: false` must win over an
|
|
210
|
+
// inherited `itemsFill: true`.
|
|
211
|
+
const fill = (item, slot) => item.fill ?? slot.itemsFill;
|
|
202
212
|
const groups = variants.items.map((variant) => {
|
|
203
213
|
const own = states.items.filter((s) => s.group === variant.key);
|
|
204
214
|
const items = own.length > 0
|
|
205
|
-
? own.map((s) => ({ key: s.key, label: s.name, node: s.node, fill: fill(s, states) }))
|
|
206
|
-
: [{ key: variant.key, label: variant.name, node: variant.node, fill: fill(variant, variants) }];
|
|
215
|
+
? own.map((s) => ({ key: s.key, label: s.name, node: s.node, fill: fill(s, states), surface: s.surface, align: s.align }))
|
|
216
|
+
: [{ key: variant.key, label: variant.name, node: variant.node, fill: fill(variant, variants), surface: variant.surface, align: variant.align }];
|
|
207
217
|
return { key: variant.key, label: variant.name, items };
|
|
208
218
|
});
|
|
209
219
|
const rest = states.items.filter((s) => s.group === undefined || !variantKeys.has(s.group));
|
|
@@ -297,3 +307,18 @@ export function choosePlacement({ available, gap, first, second }) {
|
|
|
297
307
|
export function propsColumns(count, width) {
|
|
298
308
|
return count >= 4 && width >= 2 * PROPS_MIN_COLUMN_WIDTH + PROPS_COLUMN_GAP ? 2 : 1;
|
|
299
309
|
}
|
|
310
|
+
/** How many columns the reference card's Guidance / Quick reference / Composition panel uses at a
|
|
311
|
+
* given measured inner card width: 3 only when there is confirmed composition to show as its own
|
|
312
|
+
* column AND the card is wide enough for three REFERENCE_MIN_COLUMN_WIDTH columns; 2 when wide
|
|
313
|
+
* enough for two (any composition then stays nested under Quick reference, as it always has been);
|
|
314
|
+
* 1 (stacked, full content in document order — nothing dropped) when even two columns wouldn't
|
|
315
|
+
* each keep their minimum. An unmeasured width (`<= 0`) stacks, so the first frame before layout
|
|
316
|
+
* settles never overflows. */
|
|
317
|
+
export function referenceColumns(width, hasComposition) {
|
|
318
|
+
const fits = (n) => width >= n * REFERENCE_MIN_COLUMN_WIDTH + (n - 1) * REFERENCE_COLUMN_GAP;
|
|
319
|
+
if (hasComposition && fits(3))
|
|
320
|
+
return 3;
|
|
321
|
+
if (fits(2))
|
|
322
|
+
return 2;
|
|
323
|
+
return 1;
|
|
324
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Platform, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_TYPE } from './tokens';
|
|
4
|
+
import { isFiniteOverlaySize, overlayStageDimensions, resolveOverlayViewport, type OverlaySlot } from './overlayViewport';
|
|
5
|
+
|
|
6
|
+
function isWeb(): boolean {
|
|
7
|
+
return Platform.OS === 'web' && typeof window !== 'undefined';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BoundedOverlayViewportProps {
|
|
11
|
+
/** This specimen's own page id — `SectionDef.id`. */
|
|
12
|
+
pageId: string;
|
|
13
|
+
/** Which block the demo wrapped here is authored in. */
|
|
14
|
+
slot: OverlaySlot;
|
|
15
|
+
/** That item's own key: `VariantExample.key` for `'variants'`/`'states'`, or
|
|
16
|
+
* `overlayViewport.ts`'s own `comparisonCellItemKey(rowKey, columnKey)` for a `'comparison'`
|
|
17
|
+
* cell — never inferred. */
|
|
18
|
+
itemKey: string;
|
|
19
|
+
/** `height` is the child document's exact usable viewport height, in CSS pixels — never
|
|
20
|
+
* renegotiated. `width` is only a MAXIMUM: this component adds no border or padding of its own
|
|
21
|
+
* (unlike `PhoneFrame`'s cosmetic hairline border), so this is the outer stage's widest possible
|
|
22
|
+
* size, but the stage still fills and shrinks to whatever its real owning specimen cell actually
|
|
23
|
+
* gives it at narrower widths — a fixed pixel width previously let the stage overflow a
|
|
24
|
+
* narrower owner and get silently clipped by it (see `overlayStageDimensions`). Both must be
|
|
25
|
+
* finite and strictly positive (`isFiniteOverlaySize`); an invalid value renders an honest
|
|
26
|
+
* unavailable state instead of an iframe sized to a meaningless bound. */
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
/** The real, unmodified demo — the same node that would otherwise render directly on the page. */
|
|
30
|
+
children: React.ReactNode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* An opt-in, bounded browsing context for a specimen whose demo opens a modal/sheet/portal that
|
|
35
|
+
* would otherwise escape its own card and cover the whole catalog page (RNW's Modal portals into
|
|
36
|
+
* `document.body`, so no styled wrapper or `overflow: hidden` can give it a different boundary —
|
|
37
|
+
* see the design's §5 "Bounded overlay viewport"). Opens a same-origin child document that boots
|
|
38
|
+
* the SAME app entry/module/provider tree this catalog page itself runs under, sized to
|
|
39
|
+
* `width`×`height`, so the demo's own overlay opens inside that rectangle instead of the outer
|
|
40
|
+
* document's body — the real production component, unmodified: no renderer is copied, no prop is
|
|
41
|
+
* mutated, no callback/source string is serialized or evaluated.
|
|
42
|
+
*
|
|
43
|
+
* Opt-in and additive: a page that never imports this renders exactly as it did before. Purely a
|
|
44
|
+
* documentation device, like `PhoneFrame` — a real app screen never frames its own overlays this
|
|
45
|
+
* way, since the actual device viewport already is the boundary.
|
|
46
|
+
*
|
|
47
|
+
* Browser-only containment: there is no native equivalent (React Native has no child-document
|
|
48
|
+
* boundary to open), so on a native runtime this renders `children` directly, with no boundary.
|
|
49
|
+
* Call sites must not read that fallback as native parity for the contained behavior.
|
|
50
|
+
*/
|
|
51
|
+
export function BoundedOverlayViewport({ pageId, slot, itemKey, width, height, children }: BoundedOverlayViewportProps) {
|
|
52
|
+
const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable'>('loading');
|
|
53
|
+
const web = isWeb();
|
|
54
|
+
const decision = resolveOverlayViewport({
|
|
55
|
+
address: { pageId, slot, itemKey },
|
|
56
|
+
isWeb: web,
|
|
57
|
+
currentSearch: web ? window.location.search : '',
|
|
58
|
+
pathname: web ? window.location.pathname : '',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Not web, or this document IS the selected child for this exact address: render the real demo
|
|
62
|
+
// directly — on native there is no containment to offer, and in the selected child document
|
|
63
|
+
// framing again would recurse into another, identical child document forever.
|
|
64
|
+
if (decision.mode === 'inline') return <>{children}</>;
|
|
65
|
+
|
|
66
|
+
// An invalid bound (not finite, zero, negative — a measurement that hasn't settled yet, a
|
|
67
|
+
// miscomputed layout value, an author typo) must never reach the iframe below: an honest
|
|
68
|
+
// unavailable state, not a frame opened at some browser-decided fallback size.
|
|
69
|
+
if (!isFiniteOverlaySize(width, height)) {
|
|
70
|
+
return (
|
|
71
|
+
<View style={styles.stage}>
|
|
72
|
+
<Text style={styles.status}>This example is unavailable right now.</Text>
|
|
73
|
+
</View>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<View style={[styles.stage, overlayStageDimensions(width, height)]}>
|
|
79
|
+
{status !== 'ready' && (
|
|
80
|
+
<Text style={styles.status}>{status === 'unavailable' ? 'This example is unavailable right now.' : 'Loading…'}</Text>
|
|
81
|
+
)}
|
|
82
|
+
{
|
|
83
|
+
// react-native-web has no `<Iframe>` primitive; this is the one place this component
|
|
84
|
+
// reaches past it for a genuine same-origin child document. `as unknown as string` only
|
|
85
|
+
// bypasses React Native's own JSX.IntrinsicElements typing (which never declares host-web
|
|
86
|
+
// tags) — the element itself, and every prop it's given, is still a plain, real iframe.
|
|
87
|
+
React.createElement('iframe' as unknown as string, {
|
|
88
|
+
src: decision.href,
|
|
89
|
+
title: `${pageId}: ${itemKey}`,
|
|
90
|
+
style: { ...StyleSheet.flatten(styles.frame), display: status === 'unavailable' ? 'none' : undefined },
|
|
91
|
+
onLoad: () => setStatus('ready'),
|
|
92
|
+
onError: () => setStatus('unavailable'),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
</View>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const styles = StyleSheet.create({
|
|
100
|
+
stage: {
|
|
101
|
+
backgroundColor: CATALOG_COLOR.surfacePressed,
|
|
102
|
+
alignSelf: 'center',
|
|
103
|
+
alignItems: 'center',
|
|
104
|
+
justifyContent: 'center',
|
|
105
|
+
},
|
|
106
|
+
frame: {
|
|
107
|
+
width: '100%',
|
|
108
|
+
height: '100%',
|
|
109
|
+
borderWidth: 0,
|
|
110
|
+
},
|
|
111
|
+
status: { position: 'absolute', fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
|
|
112
|
+
});
|
|
@@ -5,8 +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 { UpdatePanel } from './UpdatePanel';
|
|
8
|
-
import { groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner,
|
|
9
|
-
import
|
|
8
|
+
import { DS_VIEWER_SECRET_HEADER, footerLink, groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner, UPDATE_PAGE_ID } from './catalogNavigation';
|
|
9
|
+
import { findOverlayNode, overlayAddressFromSearch } from './overlayViewport';
|
|
10
|
+
import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus } from './types';
|
|
10
11
|
|
|
11
12
|
/** `active` can be a real page id, or the reserved update-page id — never both a generic `TId`
|
|
12
13
|
* constraint and a hardcoded string literal type at once, which is why this is its own alias. */
|
|
@@ -111,6 +112,7 @@ export function CatalogShell<TId extends string>({
|
|
|
111
112
|
sections,
|
|
112
113
|
defaultPreviewWidths,
|
|
113
114
|
update,
|
|
115
|
+
versionStatus,
|
|
114
116
|
updateEndpoint,
|
|
115
117
|
}: {
|
|
116
118
|
/** Short product/app name — the sidebar logo and the breadcrumb root. */
|
|
@@ -129,12 +131,27 @@ export function CatalogShell<TId extends string>({
|
|
|
129
131
|
* or already up to date; the viewer treats all three identically: no footer, no banner, no
|
|
130
132
|
* update page content). */
|
|
131
133
|
update?: UpdateNotice | null;
|
|
134
|
+
/** The installed version, whether automatic checks are on (and why), and the last check —
|
|
135
|
+
* `dev` writes it at startup (`.ds-viewer/version.json`), and the shell refreshes it from the
|
|
136
|
+
* local endpoint. With it, the sidebar always shows a version line and the update page offers
|
|
137
|
+
* **Check now** and the automatic-check switch. Without it (an older generated workspace), the
|
|
138
|
+
* footer appears only when an update is known, as before. */
|
|
139
|
+
versionStatus?: VersionStatus | null;
|
|
132
140
|
/** The local endpoint's base URL and per-run secret — `dev` generates both (Task 18) and
|
|
133
141
|
* `writeWorkspace`'s generated entry file bakes them in (Task 17). Absent in a build that
|
|
134
142
|
* disables the endpoint entirely — `UpdatePanel` degrades to showing the plan/notice with no
|
|
135
143
|
* **Update now** button when this is missing. */
|
|
136
144
|
updateEndpoint?: { baseUrl: string; secret: string };
|
|
137
145
|
}) {
|
|
146
|
+
// A `BoundedOverlayViewport`'s own child document (design §5): render ONLY the one authored
|
|
147
|
+
// node this document's URL addresses, with no sidebar/breadcrumb/title/pager — never the whole
|
|
148
|
+
// page, since that page's other examples (and this same example's own viewport wrapper, now
|
|
149
|
+
// recognizing itself as the selected address) would otherwise render alongside it for nothing.
|
|
150
|
+
// Read once per mount, like `active`'s own initial read below: this mode is only ever entered by
|
|
151
|
+
// a fresh document load, never a same-document transition.
|
|
152
|
+
const overlayAddress = useMemo(() => (isWeb() ? overlayAddressFromSearch(window.location.search) : undefined), []);
|
|
153
|
+
const overlayNode = useMemo(() => (overlayAddress ? findOverlayNode(sections, overlayAddress) : undefined), [overlayAddress, sections]);
|
|
154
|
+
|
|
138
155
|
const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
|
|
139
156
|
const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
|
|
140
157
|
const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys())), [groups, sectionsById]);
|
|
@@ -198,28 +215,64 @@ export function CatalogShell<TId extends string>({
|
|
|
198
215
|
|
|
199
216
|
const activeDef = active !== undefined && active !== UPDATE_PAGE_ID ? sectionsById.get(active) : undefined;
|
|
200
217
|
|
|
218
|
+
// The live version status: starts from what `dev` wrote at startup, then follows the local
|
|
219
|
+
// endpoint (a reload after **Check now** must not fall back to the startup snapshot).
|
|
220
|
+
const [liveStatus, setLiveStatus] = useState<VersionStatus | null>(versionStatus ?? null);
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
if (!updateEndpoint || !isWeb()) return;
|
|
223
|
+
let cancelled = false;
|
|
224
|
+
fetch(`${updateEndpoint.baseUrl}/version`, { headers: { [DS_VIEWER_SECRET_HEADER]: updateEndpoint.secret } })
|
|
225
|
+
.then((res) => (res.ok ? (res.json() as Promise<VersionStatus>) : undefined))
|
|
226
|
+
.then((next) => {
|
|
227
|
+
if (next && !cancelled) setLiveStatus(next);
|
|
228
|
+
})
|
|
229
|
+
.catch(() => {
|
|
230
|
+
// The startup snapshot stays; the endpoint may be restarting.
|
|
231
|
+
});
|
|
232
|
+
return () => {
|
|
233
|
+
cancelled = true;
|
|
234
|
+
};
|
|
235
|
+
}, [updateEndpoint]);
|
|
236
|
+
// With a live status, it is the truth (it includes a **Check now** result); otherwise the
|
|
237
|
+
// startup notice, as before.
|
|
238
|
+
const effectiveUpdate: UpdateNotice | null = liveStatus ? liveStatus.update : update ?? null;
|
|
239
|
+
|
|
201
240
|
const [dismissedMajor, setDismissedMajor] = useState<string | null>(() =>
|
|
202
241
|
isWeb() ? window.localStorage.getItem('ds-viewer-dismissed-major') : null,
|
|
203
242
|
);
|
|
204
243
|
const dismissMajorBanner = useCallback(() => {
|
|
205
|
-
if (!
|
|
206
|
-
if (isWeb()) window.localStorage.setItem('ds-viewer-dismissed-major',
|
|
207
|
-
setDismissedMajor(
|
|
208
|
-
}, [
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
: undefined;
|
|
244
|
+
if (!effectiveUpdate) return;
|
|
245
|
+
if (isWeb()) window.localStorage.setItem('ds-viewer-dismissed-major', effectiveUpdate.latest);
|
|
246
|
+
setDismissedMajor(effectiveUpdate.latest);
|
|
247
|
+
}, [effectiveUpdate]);
|
|
248
|
+
const link = footerLink(effectiveUpdate, liveStatus);
|
|
249
|
+
const footer = link ? { ...link, active: active === UPDATE_PAGE_ID, onPress: () => select(UPDATE_PAGE_ID) } : undefined;
|
|
212
250
|
// Design: the banner shows on every *normal* page; once you're already looking at the update
|
|
213
251
|
// page there is nothing left for "Review update" to do, so it's hidden there (confirmed against
|
|
214
252
|
// the approved mockup's own `frame()`: the banner only ever renders when `!isPanel`).
|
|
215
|
-
const showBanner = active !== UPDATE_PAGE_ID && shouldShowMajorBanner(
|
|
253
|
+
const showBanner = active !== UPDATE_PAGE_ID && shouldShowMajorBanner(effectiveUpdate, dismissedMajor);
|
|
254
|
+
|
|
255
|
+
if (overlayAddress) {
|
|
256
|
+
return (
|
|
257
|
+
<View style={styles.overlayDocumentRoot}>
|
|
258
|
+
{overlayNode ?? <Text style={styles.overlayUnavailable}>This example is no longer available.</Text>}
|
|
259
|
+
</View>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
216
262
|
|
|
217
263
|
return (
|
|
218
264
|
<View style={styles.root}>
|
|
219
265
|
<CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} />
|
|
220
266
|
<ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
|
|
221
267
|
{active === UPDATE_PAGE_ID ? (
|
|
222
|
-
<UpdatePanel
|
|
268
|
+
<UpdatePanel
|
|
269
|
+
update={effectiveUpdate}
|
|
270
|
+
versionStatus={liveStatus}
|
|
271
|
+
onVersionStatus={setLiveStatus}
|
|
272
|
+
endpoint={updateEndpoint}
|
|
273
|
+
appName={appName}
|
|
274
|
+
headingRef={headingRef}
|
|
275
|
+
/>
|
|
223
276
|
) : activeDef ? (
|
|
224
277
|
<SectionBlock
|
|
225
278
|
key={activeDef.id}
|
|
@@ -229,7 +282,7 @@ export function CatalogShell<TId extends string>({
|
|
|
229
282
|
// The approved mockup's own `PAGE_BG` order: crumb, then banner, then title — the
|
|
230
283
|
// banner used to render above the breadcrumb instead (Important finding, Fable
|
|
231
284
|
// correction pass).
|
|
232
|
-
banner={showBanner &&
|
|
285
|
+
banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} /> : undefined}
|
|
233
286
|
pager={{
|
|
234
287
|
previousId: neighbors(order, activeDef.id).previous,
|
|
235
288
|
nextId: neighbors(order, activeDef.id).next,
|
|
@@ -249,6 +302,10 @@ export function CatalogShell<TId extends string>({
|
|
|
249
302
|
|
|
250
303
|
const styles = StyleSheet.create({
|
|
251
304
|
root: { flex: 1, flexDirection: 'row', backgroundColor: CATALOG_COLOR.pageBackground },
|
|
305
|
+
// A `BoundedOverlayViewport` child document's own root: no sidebar/chrome, just the one
|
|
306
|
+
// addressed node — sized by whatever that node itself renders, not the catalog's own layout.
|
|
307
|
+
overlayDocumentRoot: { flex: 1 },
|
|
308
|
+
overlayUnavailable: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted, padding: CATALOG_SPACE.lg },
|
|
252
309
|
main: { flex: 1 },
|
|
253
310
|
mainContent: {
|
|
254
311
|
width: '100%',
|
|
@@ -43,7 +43,7 @@ function NavItem<TId extends string>({ id, label, active, onPress }: { id: TId;
|
|
|
43
43
|
);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
function FooterLink({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
|
46
|
+
function FooterLink({ label, kind, active, onPress }: { label: string; kind: 'update' | 'version'; active: boolean; onPress: () => void }) {
|
|
47
47
|
const [hovered, setHovered] = useState(false);
|
|
48
48
|
const [focused, setFocused] = useState(false);
|
|
49
49
|
return (
|
|
@@ -63,7 +63,8 @@ function FooterLink({ label, active, onPress }: { label: string; active: boolean
|
|
|
63
63
|
focused && styles.focusRing,
|
|
64
64
|
]}
|
|
65
65
|
>
|
|
66
|
-
|
|
66
|
+
{/* The dot marks an available update; the everyday version line is quiet. */}
|
|
67
|
+
{kind === 'update' && <View style={styles.footerDot} />}
|
|
67
68
|
<Text style={[styles.footerLabel, active && styles.footerLabelActive]}>{label}</Text>
|
|
68
69
|
</Pressable>
|
|
69
70
|
);
|
|
@@ -96,10 +97,10 @@ export function CatalogSidebar<TId extends string>({
|
|
|
96
97
|
labels?: ReadonlyMap<string, string>;
|
|
97
98
|
active: string | undefined;
|
|
98
99
|
onPress: (id: TId) => void;
|
|
99
|
-
/** The
|
|
100
|
-
*
|
|
101
|
-
* gets the same active styling a selected nav item would. */
|
|
102
|
-
footer?: { label: string; active: boolean; onPress: () => void };
|
|
100
|
+
/** The link below the nav list that opens the update page: the installed version (`'version'`),
|
|
101
|
+
* or the update notice with a dot (`'update'`, design §5's footer line). `active` is true while
|
|
102
|
+
* the update page itself is open — it gets the same active styling a selected nav item would. */
|
|
103
|
+
footer?: { label: string; kind?: 'update' | 'version'; active: boolean; onPress: () => void };
|
|
103
104
|
}) {
|
|
104
105
|
const [query, setQuery] = useState('');
|
|
105
106
|
// A bundled local image (the only kind `cli/workspace.ts` ever produces — see its doc comment
|
|
@@ -201,7 +202,7 @@ export function CatalogSidebar<TId extends string>({
|
|
|
201
202
|
</ScrollView>
|
|
202
203
|
{footer && (
|
|
203
204
|
<View style={styles.footerContainer}>
|
|
204
|
-
<FooterLink label={footer.label} active={footer.active} onPress={footer.onPress} />
|
|
205
|
+
<FooterLink label={footer.label} kind={footer.kind ?? 'update'} active={footer.active} onPress={footer.onPress} />
|
|
205
206
|
</View>
|
|
206
207
|
)}
|
|
207
208
|
</View>
|
|
@@ -65,8 +65,8 @@ export function ComparisonGrid({
|
|
|
65
65
|
const cell = cells.get(cellKey(row.key, c.key));
|
|
66
66
|
return (
|
|
67
67
|
cell?.node !== undefined ? (
|
|
68
|
-
<SpecimenSurface key={c.key} role="cell" surface={surface} style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
|
|
69
|
-
<SpecimenContent fill={cell.fill}>{cell.node}</SpecimenContent>
|
|
68
|
+
<SpecimenSurface key={c.key} role="cell" surface={cell.surface ?? surface} style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
|
|
69
|
+
<SpecimenContent fill={cell.fill} align={cell.align}>{cell.node}</SpecimenContent>
|
|
70
70
|
</SpecimenSurface>
|
|
71
71
|
) : (
|
|
72
72
|
<View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
|
|
@@ -47,8 +47,8 @@ export function ComparisonGroups({
|
|
|
47
47
|
<View style={styles.caption}>
|
|
48
48
|
<Text style={styles.captionText}>{item.label}</Text>
|
|
49
49
|
</View>
|
|
50
|
-
<SpecimenSurface surface={surface} style={styles.specimen}>
|
|
51
|
-
<SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
|
|
50
|
+
<SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
|
|
51
|
+
<SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
|
|
52
52
|
</SpecimenSurface>
|
|
53
53
|
</View>
|
|
54
54
|
);
|
|
@@ -61,8 +61,8 @@ export function ComparisonList({
|
|
|
61
61
|
<View style={styles.caption}>
|
|
62
62
|
<Text style={styles.captionText}>{item.label}</Text>
|
|
63
63
|
</View>
|
|
64
|
-
<SpecimenSurface surface={surface} style={styles.specimen}>
|
|
65
|
-
<SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
|
|
64
|
+
<SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
|
|
65
|
+
<SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
|
|
66
66
|
</SpecimenSurface>
|
|
67
67
|
</View>
|
|
68
68
|
);
|
|
@@ -16,6 +16,11 @@ const HEIGHT = 480;
|
|
|
16
16
|
* `width: '100%'` capped at 280 (not a fixed width) — a fixed px width could exceed the card's real
|
|
17
17
|
* content width once the surrounding column shrinks below its own max-width cap (it does at common
|
|
18
18
|
* viewport sizes), overflowing past the card's edge since the card itself doesn't clip.
|
|
19
|
+
*
|
|
20
|
+
* `alignSelf: 'center'` is this frame's own responsibility, not its `SpecimenContent` wrapper's:
|
|
21
|
+
* a `fill` specimen's wrapper now stretches by default (preserving an unrelated full-width
|
|
22
|
+
* auto-sized specimen elsewhere), which leaves a capped-width child like this one flush to one
|
|
23
|
+
* side instead of centered — see `specimenContentAlignItems` in `specimenSurfaceStyle.ts`.
|
|
19
24
|
*/
|
|
20
25
|
export function PhoneFrame({ children }: { children: React.ReactNode }) {
|
|
21
26
|
return <View style={styles.frame}>{children}</View>;
|
|
@@ -33,5 +38,6 @@ const styles = StyleSheet.create({
|
|
|
33
38
|
overflow: 'hidden',
|
|
34
39
|
alignItems: 'center',
|
|
35
40
|
justifyContent: 'center',
|
|
41
|
+
alignSelf: 'center',
|
|
36
42
|
},
|
|
37
43
|
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
|
-
import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
|
|
2
|
+
import { View, Text, StyleSheet, type LayoutChangeEvent, type TextStyle } from 'react-native';
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
|
|
4
4
|
import { PropsTable } from './PropsTable';
|
|
5
|
-
import { propsColumns } from './comparison';
|
|
6
|
-
import type { SectionDef } from './types';
|
|
5
|
+
import { propsColumns, referenceColumns } from './comparison';
|
|
6
|
+
import type { ComposedOfEntry, SectionDef } from './types';
|
|
7
7
|
|
|
8
8
|
// react-native-web reads `aria-level`; React Native's prop types do not declare it.
|
|
9
9
|
const HEADING_LEVEL_2 = { 'aria-level': 2 } as Record<string, unknown>;
|
|
@@ -40,13 +40,51 @@ function PropsBox<TId extends string>({ def }: { def: SectionDef<TId> }) {
|
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// `built-in` is composition's assumed default relationship, so spelling it out reads redundantly
|
|
44
|
+
// (e.g. "TrainArrivalRemainingTime · built-in"); `slot`/`related` still need their own suffix to
|
|
45
|
+
// stay distinguishable from a plain built-in entry.
|
|
46
|
+
function compositionLabel(entry: ComposedOfEntry): string {
|
|
47
|
+
return entry.relationship === 'built-in' ? entry.component : `${entry.component} · ${entry.relationship}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function CompositionFacts({ composedOf }: { composedOf: ComposedOfEntry[] }) {
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
{composedOf.map((entry, i) => (
|
|
54
|
+
<Fact key={`${entry.component}-${entry.relationship}-${i}`} label={compositionLabel(entry)} value={entry.role} />
|
|
55
|
+
))}
|
|
56
|
+
</>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
/**
|
|
44
|
-
* Always-visible reference below a page's specimens: a card with Guidance
|
|
45
|
-
* (source path, accessibility),
|
|
46
|
-
*
|
|
61
|
+
* Always-visible reference below a page's specimens: a card with Guidance, Quick reference
|
|
62
|
+
* (source path, accessibility), and — only when the page has confirmed composition to disclose —
|
|
63
|
+
* Composition, then Props in their own box. Token galleries show only Quick reference with the
|
|
64
|
+
* source path. No collapse control by design.
|
|
65
|
+
*
|
|
66
|
+
* Guidance/Quick reference/Composition lay out from the card's own measured INNER width
|
|
67
|
+
* (`referenceColumns`), not viewport width alone: three columns only when there is confirmed
|
|
68
|
+
* composition and the card is wide enough for all three (Composition gets its own column only
|
|
69
|
+
* then — otherwise it stays nested under Quick reference, as it always has); two when there's
|
|
70
|
+
* room for Guidance and Quick reference side by side; one (stacked, full document order, nothing
|
|
71
|
+
* dropped) when the card is too narrow even for two. `minWidth: 0` on every column lets its facts
|
|
72
|
+
* and source path actually shrink and wrap at that width instead of pushing the card wider than
|
|
73
|
+
* its own container.
|
|
74
|
+
*
|
|
75
|
+
* `onLayout` sits on the `columns` row itself, not the padded/bordered `card` around it:
|
|
76
|
+
* `referenceColumns` fits each column against `REFERENCE_MIN_COLUMN_WIDTH`, an INNER measurement —
|
|
77
|
+
* the card's own padding (`panelPadding`) and 1px border never belong in that budget. Measuring the
|
|
78
|
+
* outer card previously fed `referenceColumns` an outer width up to ~42px wider than what the three
|
|
79
|
+
* columns' content actually has, which can report a column count the inner content doesn't fit.
|
|
47
80
|
*/
|
|
48
81
|
export function ReferenceDetails<TId extends string>({ def }: { def: SectionDef<TId> }) {
|
|
49
82
|
const hide = def.hide ?? {};
|
|
83
|
+
const [width, setWidth] = useState(0);
|
|
84
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
85
|
+
const next = Math.round(event.nativeEvent.layout.width);
|
|
86
|
+
if (next > 0 && next !== width) setWidth(next);
|
|
87
|
+
};
|
|
50
88
|
if (def.tokenGallery) {
|
|
51
89
|
return (
|
|
52
90
|
<View style={styles.card}>
|
|
@@ -55,27 +93,37 @@ export function ReferenceDetails<TId extends string>({ def }: { def: SectionDef<
|
|
|
55
93
|
</View>
|
|
56
94
|
);
|
|
57
95
|
}
|
|
96
|
+
const hasComposition = Boolean(def.composedOf && def.composedOf.length > 0);
|
|
97
|
+
const columns = referenceColumns(width, hasComposition);
|
|
98
|
+
const stacked = columns === 1;
|
|
99
|
+
const compositionIsOwnColumn = columns === 3 && hasComposition;
|
|
100
|
+
const columnStyle = stacked ? styles.columnStacked : styles.column;
|
|
101
|
+
|
|
58
102
|
return (
|
|
59
103
|
<View style={styles.stack}>
|
|
60
104
|
<View style={styles.card}>
|
|
61
|
-
<View style={styles.columns}>
|
|
62
|
-
<View style={
|
|
105
|
+
<View style={[styles.columns, stacked && styles.columnsStacked]} onLayout={onLayout}>
|
|
106
|
+
<View style={columnStyle}>
|
|
63
107
|
<Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Guidance</Text>
|
|
64
108
|
<Text style={styles.body}>{def.whenToUse ?? 'No usage guidance documented.'}</Text>
|
|
65
109
|
</View>
|
|
66
|
-
<View style={
|
|
110
|
+
<View style={columnStyle}>
|
|
67
111
|
<Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Quick reference</Text>
|
|
68
112
|
{def.path && <Fact label="Source" value={def.path} mono />}
|
|
69
113
|
{!hide.accessibility && <Fact label="Accessibility" value={def.a11y ?? 'No accessibility notes documented.'} />}
|
|
70
|
-
{
|
|
114
|
+
{!compositionIsOwnColumn && hasComposition && def.composedOf && (
|
|
71
115
|
<>
|
|
72
116
|
<Text style={styles.subheading}>Composition</Text>
|
|
73
|
-
{def.composedOf
|
|
74
|
-
<Fact key={`${entry.component}-${entry.relationship}-${i}`} label={`${entry.component} · ${entry.relationship}`} value={entry.role} />
|
|
75
|
-
))}
|
|
117
|
+
<CompositionFacts composedOf={def.composedOf} />
|
|
76
118
|
</>
|
|
77
119
|
)}
|
|
78
120
|
</View>
|
|
121
|
+
{compositionIsOwnColumn && def.composedOf && (
|
|
122
|
+
<View style={columnStyle}>
|
|
123
|
+
<Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Composition</Text>
|
|
124
|
+
<CompositionFacts composedOf={def.composedOf} />
|
|
125
|
+
</View>
|
|
126
|
+
)}
|
|
79
127
|
</View>
|
|
80
128
|
</View>
|
|
81
129
|
{!hide.props && <PropsBox def={def} />}
|
|
@@ -93,7 +141,9 @@ const styles = StyleSheet.create({
|
|
|
93
141
|
padding: CATALOG_LAYOUT.panelPadding,
|
|
94
142
|
},
|
|
95
143
|
columns: { flexDirection: 'row', gap: CATALOG_SPACE['2xl'] },
|
|
96
|
-
|
|
144
|
+
columnsStacked: { flexDirection: 'column' },
|
|
145
|
+
column: { flex: 1, minWidth: 0 },
|
|
146
|
+
columnStacked: { minWidth: 0 },
|
|
97
147
|
heading: {
|
|
98
148
|
fontSize: CATALOG_TYPE.panelHeading,
|
|
99
149
|
fontWeight: '700',
|
|
@@ -111,15 +161,20 @@ const styles = StyleSheet.create({
|
|
|
111
161
|
marginBottom: CATALOG_SPACE.xs,
|
|
112
162
|
},
|
|
113
163
|
fact: {
|
|
114
|
-
flexDirection: '
|
|
115
|
-
|
|
116
|
-
gap: CATALOG_SPACE.lg,
|
|
164
|
+
flexDirection: 'column',
|
|
165
|
+
gap: CATALOG_SPACE.xs,
|
|
117
166
|
paddingVertical: CATALOG_LAYOUT.factPaddingY,
|
|
118
167
|
borderBottomWidth: 1,
|
|
119
168
|
borderBottomColor: CATALOG_COLOR.borderSubtle,
|
|
120
169
|
},
|
|
121
|
-
factLabel: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
|
|
122
|
-
factValue: {
|
|
123
|
-
|
|
170
|
+
factLabel: { flexShrink: 1, minWidth: 0, fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
|
|
171
|
+
factValue: { minWidth: 0, fontSize: CATALOG_TYPE.md, lineHeight: 20, textAlign: 'left', color: CATALOG_COLOR.textMuted },
|
|
172
|
+
// `wordBreak` isn't in the installed `TextStyle` type (RN's own, not RNW's — RNW's own
|
|
173
|
+
// `Text/types.js` declares it; a browser applies any valid CSS property name it recognizes
|
|
174
|
+
// regardless), so a bare literal here would report an unknown property under `--noEmit` even
|
|
175
|
+
// though it genuinely works: a long unbroken token (a source path with no spaces, e.g.
|
|
176
|
+
// `AnimatedChevron.tsx`) has no space for the browser's default line-breaking to use, so without
|
|
177
|
+
// this it stays one unbroken run and overflows a narrow card instead of wrapping at its '/'s.
|
|
178
|
+
mono: { fontFamily: CATALOG_COLOR.code, wordBreak: 'break-word' } as TextStyle,
|
|
124
179
|
empty: { fontSize: CATALOG_TYPE.sm, fontStyle: 'italic', color: CATALOG_COLOR.textMuted },
|
|
125
180
|
});
|