@krapwoo/ds-viewer 0.4.7 → 0.4.8
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 +21 -6
- package/dist/cli/doctor.js +4 -2
- package/dist/cli/init.js +5 -8
- package/dist/native/catalog/comparison.js +17 -8
- package/native/catalog/CatalogShell.tsx +5 -1
- package/native/catalog/ComparisonGrid.tsx +5 -1
- package/native/catalog/ComparisonGroups.tsx +7 -1
- package/native/catalog/ComparisonList.tsx +7 -1
- package/native/catalog/PhoneFrame.tsx +108 -24
- package/native/catalog/ReferenceDetails.tsx +8 -0
- package/native/catalog/SectionBlock.tsx +10 -4
- package/native/catalog/SpecimenAddress.tsx +15 -0
- package/native/catalog/SpecimenSurface.tsx +66 -4
- package/native/catalog/comparison.ts +21 -8
- package/native/catalog/deviceFrame.ts +14 -0
- package/native/catalog/overlayViewport.ts +4 -2
- package/native/catalog/specimenSurfaceStyle.ts +54 -0
- package/package.json +5 -4
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -1
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -1
- package/starter-kit/components/Badge/Badge.catalog.tsx +1 -1
- package/starter-kit/components/Banner/Banner.catalog.tsx +2 -1
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +5 -1
- package/starter-kit/components/Button/Button.catalog.tsx +1 -1
- package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +8 -1
- package/starter-kit/components/Card/Card.catalog.tsx +2 -1
- package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +2 -2
- package/starter-kit/components/Dialog/Dialog.catalog.tsx +1 -1
- package/starter-kit/components/Divider/Divider.catalog.tsx +2 -1
- package/starter-kit/components/Dock/Dock.catalog.tsx +4 -1
- package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +1 -1
- package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +2 -1
- package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +5 -1
- package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +1 -1
- package/starter-kit/components/InputField/InputField.catalog.tsx +3 -1
- package/starter-kit/components/List/List.catalog.tsx +2 -1
- package/starter-kit/components/ListItem/ListItem.catalog.tsx +7 -1
- package/starter-kit/components/Loading/Loading.catalog.tsx +2 -1
- package/starter-kit/components/Pill/Pill.catalog.tsx +1 -1
- package/starter-kit/components/PillRow/PillRow.catalog.tsx +1 -1
- package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +1 -1
- package/starter-kit/components/Radio/Radio.catalog.tsx +2 -2
- package/starter-kit/components/SearchField/SearchField.catalog.tsx +2 -1
- package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +2 -1
- package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +1 -3
- package/starter-kit/components/Shimmer/Breathing.catalog.tsx +2 -1
- package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +2 -1
- package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +5 -1
- package/starter-kit/components/Surface/Surface.catalog.tsx +2 -1
- package/starter-kit/components/Switch/Switch.catalog.tsx +2 -2
- package/starter-kit/components/TextArea/TextArea.catalog.tsx +2 -1
- package/starter-kit/components/Toast/Toast.catalog.tsx +2 -1
- package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +2 -1
- package/starter-kit/components/TopNav/TopNav.catalog.tsx +4 -1
- package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +1 -3
- package/starter-kit/pages/ReportIssue.catalog.tsx +18 -1
- package/starter-kit/pages/SavedTrips.catalog.tsx +27 -6
- package/starter-kit/pages/TripPlannerForm.catalog.tsx +7 -1
package/README.md
CHANGED
|
@@ -92,9 +92,15 @@ npm run kit:dev # builds the CLI, then opens the starter kit's own cata
|
|
|
92
92
|
npm run check:catalog # the same headless check CI runs — every page, zero console errors
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
The viewer's own building blocks (`CatalogShell`, `SectionBlock`, `ComparisonGrid`, the catalog's
|
|
96
|
+
own colours and type, …) are documented in a separate catalog for people working on ds-viewer
|
|
97
|
+
itself, so the starter kit's catalog shows only what a project using the kit gets:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npm run viewer:dev # opens viewer-host/, the DS Viewer internals catalog (not shipped)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`check:catalog`, `check:doctor` and `check:types` cover both catalogs.
|
|
98
104
|
|
|
99
105
|
## Contributing
|
|
100
106
|
|
|
@@ -259,6 +265,15 @@ actually apply:
|
|
|
259
265
|
the one child document that the address itself selects, `children` render inline with no
|
|
260
266
|
boundary — never read either case as native containment parity.
|
|
261
267
|
- `previewWidths: [402, 320]` — extra preview widths for a `render()` page (each capped at 402).
|
|
268
|
+
Add a second width only when the component looks different there (labels truncate, a row
|
|
269
|
+
wraps); otherwise it's the same picture twice. Leave it out by default.
|
|
270
|
+
- **`PhoneFrame`** — wrap a demo that is a whole screen or opens an overlay (a sheet, a dialog, a
|
|
271
|
+
toast sliding in, a dropdown's picker) in `<PhoneFrame>`. It is a real device: the demo lays out
|
|
272
|
+
in an iPhone SE viewport (375 × 667 points), scaled down only when its cell is narrower. In the
|
|
273
|
+
browser viewer it opens that example in its own device-sized document, so overlays that portal
|
|
274
|
+
to the page (React Native's `Modal`) open inside the phone, not over the catalog.
|
|
275
|
+
- Full-width (`itemsFill`) variants and states wrap at most **3** across; set `maxColumns` to
|
|
276
|
+
change that. A filled example that keeps its own fixed width is centred in its cell.
|
|
262
277
|
Default to one preview; add a second width only when a narrower (or wider) viewport actually
|
|
263
278
|
changes something worth comparing — wrapping, truncation, or reflow — not just to show the same,
|
|
264
279
|
identically-laid-out content twice. `CatalogShell`'s `defaultPreviewWidths="full"` keeps a
|
|
@@ -308,9 +323,9 @@ path. For anything beyond a single list of tokens, use the token-page layouts be
|
|
|
308
323
|
`Components`. A page's `group` is a plain string shown as its sidebar section label;
|
|
309
324
|
`groupOrder` (e.g. `['Tokens', 'Components', 'Patterns', 'Recipes', 'Experiences']`) only
|
|
310
325
|
controls which order those sections appear in, top to bottom — any group not listed sorts
|
|
311
|
-
alphabetically after the ones that are.
|
|
312
|
-
`Components`
|
|
313
|
-
|
|
326
|
+
alphabetically after the ones that are. The starter kit uses `Tokens`, then every
|
|
327
|
+
component under `Components`, then `Patterns` (whole screens built from the kit, such as
|
|
328
|
+
SavedTrips) and `Reference` (the generated manifest). A larger catalog might add `Recipes` or
|
|
314
329
|
`Experiences` (full flows) alongside them. Pages sort A–Z inside each group regardless of how
|
|
315
330
|
many groups you have. Avoid splitting `Components` into finer categories such as "Actions" or
|
|
316
331
|
"Status" purely to browse by — that makes readers guess where a component lives; search and A–Z
|
package/dist/cli/doctor.js
CHANGED
|
@@ -480,7 +480,9 @@ export function checkPageTypeErrors(pages, options = {}) {
|
|
|
480
480
|
// `createComponentReader` (where a mismatch only degrades prop reading silently; here it would
|
|
481
481
|
// produce CI-failing errors the host's own `tsc` would never raise). Only `noEmit`,
|
|
482
482
|
// `skipLibCheck`, and the merged `paths` are forced on top.
|
|
483
|
-
|
|
483
|
+
// `readHostTsconfigOptions` searches upward from the given file's folder; a file name inside
|
|
484
|
+
// `hostRoot` starts that search at the host's own root.
|
|
485
|
+
const host = readHostTsconfigOptions(options.hostRoot ? path.join(options.hostRoot, 'ds-viewer.config.ts') : pageFiles[0]);
|
|
484
486
|
const compilerOptions = {
|
|
485
487
|
...host.options,
|
|
486
488
|
noEmit: true,
|
|
@@ -569,7 +571,7 @@ export function runDoctor(config) {
|
|
|
569
571
|
: undefined;
|
|
570
572
|
let issues = [
|
|
571
573
|
...collectIssues(pages, components),
|
|
572
|
-
...checkPageTypeErrors(pages, { fallbackPaths }),
|
|
574
|
+
...checkPageTypeErrors(pages, { fallbackPaths, hostRoot: config.projectRoot }),
|
|
573
575
|
...collectGlobalIssues({ syncWarnings: syncResult.warnings, tokenFileCount }),
|
|
574
576
|
];
|
|
575
577
|
// Design's own `--json` example gives a project-relative `file`; `StaticPage.file` is absolute
|
package/dist/cli/init.js
CHANGED
|
@@ -141,14 +141,11 @@ function ensureSharedProjectFiles(projectRoot) {
|
|
|
141
141
|
return written;
|
|
142
142
|
}
|
|
143
143
|
const KIT_REQUIRED_PACKAGES = ['react-native-svg', 'react-native-safe-area-context'];
|
|
144
|
-
/** The starter kit's own
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
const KIT_GROUP_ORDER = [
|
|
149
|
-
'Actions', 'Surfaces', 'Inputs', 'Controls', 'Selection', 'Feedback', 'Navigation',
|
|
150
|
-
'Overlays', 'Layout', 'Sub-Parts', 'Recipes', 'Tokens', 'Reference',
|
|
151
|
-
];
|
|
144
|
+
/** The starter kit's own sidebar order (the same list kit-host/ds-viewer.config.ts declares),
|
|
145
|
+
* written into a fresh project's config. Follows the README's "Organising the catalog": Tokens,
|
|
146
|
+
* then every component in one A–Z group, then the non-component kinds of page (Patterns: whole
|
|
147
|
+
* screens built from the kit; Reference: the generated manifest). */
|
|
148
|
+
const KIT_GROUP_ORDER = ['Tokens', 'Components', 'Patterns', 'Reference'];
|
|
152
149
|
/** Default `installer` — a real `npx expo install`, run only for whichever of
|
|
153
150
|
* `KIT_REQUIRED_PACKAGES` the project doesn't already depend on. Test-injected in every unit test
|
|
154
151
|
* (this plan's Global Constraints: never run a real `expo install` in tests). */
|
|
@@ -189,10 +189,19 @@ export function remainingStates(states, comparison) {
|
|
|
189
189
|
export function slotSize(def, slot) {
|
|
190
190
|
return def.specimenSize ?? (slot.itemsFill ? 'wide' : 'regular');
|
|
191
191
|
}
|
|
192
|
-
|
|
192
|
+
/** Full-width (filled) examples are block components (cards, banners, rows): narrower than about a
|
|
193
|
+
* third of a laptop's content width they stop reading as themselves, so they wrap at most 3
|
|
194
|
+
* across unless the page sets its own `maxColumns`. Small centred examples wrap freely. */
|
|
195
|
+
export const FILLED_SLOT_MAX_COLUMNS = 3;
|
|
196
|
+
function slotColumnCap(slot) {
|
|
197
|
+
if (slot.maxColumns !== undefined)
|
|
198
|
+
return { maxColumns: slot.maxColumns };
|
|
199
|
+
return slot.itemsFill ? { maxColumns: FILLED_SLOT_MAX_COLUMNS } : {};
|
|
200
|
+
}
|
|
201
|
+
function slotItems(slot, from) {
|
|
193
202
|
// Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
|
|
194
203
|
// `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 }));
|
|
204
|
+
return slot.items.map((item) => ({ key: item.key, slot: from, label: item.name, node: item.node, fill: item.fill ?? slot.itemsFill, surface: item.surface, align: item.align }));
|
|
196
205
|
}
|
|
197
206
|
/** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
|
|
198
207
|
function previewWidths(requested, fallback) {
|
|
@@ -212,8 +221,8 @@ function groupStates(variants, states) {
|
|
|
212
221
|
const groups = variants.items.map((variant) => {
|
|
213
222
|
const own = states.items.filter((s) => s.group === variant.key);
|
|
214
223
|
const items = own.length > 0
|
|
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 }];
|
|
224
|
+
? own.map((s) => ({ key: s.key, slot: 'states', label: s.name, node: s.node, fill: fill(s, states), surface: s.surface, align: s.align }))
|
|
225
|
+
: [{ key: variant.key, slot: 'variants', label: variant.name, node: variant.node, fill: fill(variant, variants), surface: variant.surface, align: variant.align }];
|
|
217
226
|
return { key: variant.key, label: variant.name, items };
|
|
218
227
|
});
|
|
219
228
|
const rest = states.items.filter((s) => s.group === undefined || !variantKeys.has(s.group));
|
|
@@ -260,8 +269,8 @@ export function presentationBlocks(def, options = {}) {
|
|
|
260
269
|
kind: 'list',
|
|
261
270
|
title: 'Variants',
|
|
262
271
|
size: slotSize(def, def.variants),
|
|
263
|
-
items: slotItems(def.variants),
|
|
264
|
-
...(def.variants
|
|
272
|
+
items: slotItems(def.variants, 'variants'),
|
|
273
|
+
...slotColumnCap(def.variants),
|
|
265
274
|
});
|
|
266
275
|
}
|
|
267
276
|
else if (def.render) {
|
|
@@ -274,8 +283,8 @@ export function presentationBlocks(def, options = {}) {
|
|
|
274
283
|
kind: 'list',
|
|
275
284
|
title: secondary ? 'Other configurations' : 'States / configurations',
|
|
276
285
|
size: slotSize(def, states),
|
|
277
|
-
items: slotItems(states),
|
|
278
|
-
...(states
|
|
286
|
+
items: slotItems(states, 'states'),
|
|
287
|
+
...slotColumnCap(states),
|
|
279
288
|
});
|
|
280
289
|
}
|
|
281
290
|
if (blocks.length === 0 && !hide.variants && !hide.states) {
|
|
@@ -7,6 +7,7 @@ import { SectionBlock } from './SectionBlock';
|
|
|
7
7
|
import { UpdatePanel } from './UpdatePanel';
|
|
8
8
|
import { DS_VIEWER_SECRET_HEADER, footerLink, groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner, UPDATE_PAGE_ID } from './catalogNavigation';
|
|
9
9
|
import { findOverlayNode, overlayAddressFromSearch } from './overlayViewport';
|
|
10
|
+
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
10
11
|
import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus } from './types';
|
|
11
12
|
|
|
12
13
|
/** `active` can be a real page id, or the reserved update-page id — never both a generic `TId`
|
|
@@ -255,7 +256,10 @@ export function CatalogShell<TId extends string>({
|
|
|
255
256
|
if (overlayAddress) {
|
|
256
257
|
return (
|
|
257
258
|
<View style={styles.overlayDocumentRoot}>
|
|
258
|
-
{
|
|
259
|
+
{/* The addressed example's own device frame recognises this document as its viewport. */}
|
|
260
|
+
<SpecimenAddressProvider address={overlayAddress}>
|
|
261
|
+
{overlayNode ?? <Text style={styles.overlayUnavailable}>This example is no longer available.</Text>}
|
|
262
|
+
</SpecimenAddressProvider>
|
|
259
263
|
</View>
|
|
260
264
|
);
|
|
261
265
|
}
|
|
@@ -3,6 +3,8 @@ import { View, Text, ScrollView, StyleSheet } from 'react-native';
|
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
|
|
4
4
|
import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, axisItems, cellKey, gridWidthBounds, indexCells, validateComparison } from './comparison';
|
|
5
5
|
import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
|
+
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
7
|
+
import { comparisonCellItemKey } from './overlayViewport';
|
|
6
8
|
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
7
9
|
import type { ComparisonDef, SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
8
10
|
|
|
@@ -66,7 +68,9 @@ export function ComparisonGrid({
|
|
|
66
68
|
return (
|
|
67
69
|
cell?.node !== undefined ? (
|
|
68
70
|
<SpecimenSurface key={c.key} role="cell" surface={cell.surface ?? surface} style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
|
|
69
|
-
<
|
|
71
|
+
<SpecimenAddressProvider address={{ pageId: sectionId, slot: 'comparison', itemKey: comparisonCellItemKey(row.key, c.key) }}>
|
|
72
|
+
<SpecimenContent fill={cell.fill} align={cell.align}>{cell.node}</SpecimenContent>
|
|
73
|
+
</SpecimenAddressProvider>
|
|
70
74
|
</SpecimenSurface>
|
|
71
75
|
) : (
|
|
72
76
|
<View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
|
|
@@ -3,6 +3,7 @@ import { View, Text, ScrollView, StyleSheet } from 'react-native';
|
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
|
|
4
4
|
import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, type ListGroup } from './comparison';
|
|
5
5
|
import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
|
+
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
6
7
|
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
7
8
|
import type { SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
8
9
|
|
|
@@ -18,10 +19,13 @@ export function ComparisonGroups({
|
|
|
18
19
|
size,
|
|
19
20
|
label,
|
|
20
21
|
surface = DEFAULT_SPECIMEN_SURFACE,
|
|
22
|
+
pageId,
|
|
21
23
|
}: {
|
|
22
24
|
groups: ListGroup[];
|
|
23
25
|
size: SpecimenSize;
|
|
24
26
|
label: string;
|
|
27
|
+
/** The page these examples belong to, so a device frame inside one can address it. */
|
|
28
|
+
pageId?: string;
|
|
25
29
|
surface?: SpecimenSurfaceKind;
|
|
26
30
|
}) {
|
|
27
31
|
const columns = Math.max(1, ...groups.map((g) => g.items.length));
|
|
@@ -48,7 +52,9 @@ export function ComparisonGroups({
|
|
|
48
52
|
<Text style={styles.captionText}>{item.label}</Text>
|
|
49
53
|
</View>
|
|
50
54
|
<SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
|
|
51
|
-
<
|
|
55
|
+
<SpecimenAddressProvider address={pageId && item.slot ? { pageId, slot: item.slot, itemKey: item.key } : undefined}>
|
|
56
|
+
<SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
|
|
57
|
+
</SpecimenAddressProvider>
|
|
52
58
|
</SpecimenSurface>
|
|
53
59
|
</View>
|
|
54
60
|
);
|
|
@@ -3,6 +3,7 @@ import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
|
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
|
|
4
4
|
import { MATRIX_LAYOUT, listGeometry, type ListItem } from './comparison';
|
|
5
5
|
import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
|
+
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
6
7
|
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
7
8
|
import type { SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
8
9
|
|
|
@@ -19,10 +20,13 @@ export function ComparisonList({
|
|
|
19
20
|
label,
|
|
20
21
|
maxColumns,
|
|
21
22
|
surface = DEFAULT_SPECIMEN_SURFACE,
|
|
23
|
+
pageId,
|
|
22
24
|
}: {
|
|
23
25
|
items: ListItem[];
|
|
24
26
|
size: SpecimenSize;
|
|
25
27
|
label: string;
|
|
28
|
+
/** The page these examples belong to, so a device frame inside one can address it. */
|
|
29
|
+
pageId?: string;
|
|
26
30
|
maxColumns?: 1 | 2 | 3 | 4 | 5;
|
|
27
31
|
surface?: SpecimenSurfaceKind;
|
|
28
32
|
}) {
|
|
@@ -62,7 +66,9 @@ export function ComparisonList({
|
|
|
62
66
|
<Text style={styles.captionText}>{item.label}</Text>
|
|
63
67
|
</View>
|
|
64
68
|
<SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
|
|
65
|
-
<
|
|
69
|
+
<SpecimenAddressProvider address={pageId && item.slot ? { pageId, slot: item.slot, itemKey: item.key } : undefined}>
|
|
70
|
+
<SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
|
|
71
|
+
</SpecimenAddressProvider>
|
|
66
72
|
</SpecimenSurface>
|
|
67
73
|
</View>
|
|
68
74
|
);
|
|
@@ -1,36 +1,100 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import {
|
|
3
|
-
import { CATALOG_RADIUS,
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Platform, StyleSheet, Text, View, type LayoutChangeEvent } from 'react-native';
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
|
|
4
|
+
import { DEVICE_FRAME, deviceFrameScale } from './deviceFrame';
|
|
5
|
+
import { resolveOverlayViewport } from './overlayViewport';
|
|
6
|
+
import { useSpecimenAddress } from './SpecimenAddress';
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
8
|
+
function isWeb(): boolean {
|
|
9
|
+
return Platform.OS === 'web' && typeof window !== 'undefined';
|
|
10
|
+
}
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
|
-
* A
|
|
10
|
-
* picker) inside a fixed-size box instead of letting its absolutely-positioned overlay cover the
|
|
11
|
-
* whole documentation page. Purely a documentation device: a real app screen never needs to fake
|
|
12
|
-
* being a phone screen inside itself, since the actual device viewport already does that job — that's
|
|
13
|
-
* why this lives in the catalog framework (like VariantGroup/TokenRow) rather than the host app's
|
|
14
|
-
* own component library (a downstream app would never import this file).
|
|
13
|
+
* A real phone screen for a live demo: a screen, a sheet, a dialog, a toast sliding in.
|
|
15
14
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* - **Real dimensions.** The demo lays out in a viewport of exactly `DEVICE_FRAME` (iPhone SE,
|
|
16
|
+
* 375 × 667 points). A cell narrower than that scales the whole device down rather than
|
|
17
|
+
* squeezing its layout.
|
|
18
|
+
* - **A real viewport on the web.** Inside a catalog example, the frame opens that one example in
|
|
19
|
+
* its own child document at device size (the same mechanism as `BoundedOverlayViewport`). Its
|
|
20
|
+
* window is the device: anything that portals to the document body, such as React Native's
|
|
21
|
+
* `Modal`, opens inside the phone instead of over the whole catalog, and the window reports the
|
|
22
|
+
* device's size.
|
|
23
|
+
* - **Elsewhere** (native, or a frame outside a catalog example) it renders inline at the same
|
|
24
|
+
* size; overlays that portal to the body are then not contained.
|
|
19
25
|
*
|
|
20
|
-
*
|
|
21
|
-
* a
|
|
22
|
-
*
|
|
23
|
-
* side instead of centered — see `specimenContentAlignItems` in `specimenSurfaceStyle.ts`.
|
|
26
|
+
* Centred in its cell by its own full-width measuring wrapper, whatever the cell's alignment.
|
|
27
|
+
* Purely a documentation device: a real app screen never frames itself, so this lives in the
|
|
28
|
+
* catalog framework rather than in an app's component library.
|
|
24
29
|
*/
|
|
25
30
|
export function PhoneFrame({ children }: { children: React.ReactNode }) {
|
|
26
|
-
|
|
31
|
+
const address = useSpecimenAddress();
|
|
32
|
+
const [available, setAvailable] = useState(0);
|
|
33
|
+
const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable'>('loading');
|
|
34
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
35
|
+
const width = Math.floor(event.nativeEvent.layout.width);
|
|
36
|
+
if (width > 0 && width !== available) setAvailable(width);
|
|
37
|
+
};
|
|
38
|
+
const scale = deviceFrameScale(available);
|
|
39
|
+
// Until the cell is measured, fit it at the device's proportions rather than drawing at full size
|
|
40
|
+
// for a frame (which would briefly overflow a cell narrower than the device).
|
|
41
|
+
const box = available > 0
|
|
42
|
+
? { width: DEVICE_FRAME.width * scale, height: DEVICE_FRAME.height * scale }
|
|
43
|
+
: { width: '100%' as const, maxWidth: DEVICE_FRAME.width, aspectRatio: DEVICE_FRAME.width / DEVICE_FRAME.height };
|
|
44
|
+
|
|
45
|
+
if (isWeb() && address) {
|
|
46
|
+
const decision = resolveOverlayViewport({
|
|
47
|
+
address,
|
|
48
|
+
isWeb: true,
|
|
49
|
+
currentSearch: window.location.search,
|
|
50
|
+
pathname: window.location.pathname,
|
|
51
|
+
});
|
|
52
|
+
// This document is the device itself: fill it edge to edge.
|
|
53
|
+
if (decision.mode === 'inline') return <View style={styles.screenFill}>{children}</View>;
|
|
54
|
+
return (
|
|
55
|
+
<View style={styles.measure} onLayout={onLayout}>
|
|
56
|
+
<View style={[styles.device, box]}>
|
|
57
|
+
{status !== 'ready' && (
|
|
58
|
+
<Text style={styles.status}>{status === 'unavailable' ? 'This example is unavailable right now.' : 'Loading…'}</Text>
|
|
59
|
+
)}
|
|
60
|
+
{
|
|
61
|
+
// react-native-web has no iframe primitive; this is a plain same-origin iframe.
|
|
62
|
+
React.createElement('iframe' as unknown as string, {
|
|
63
|
+
src: decision.href,
|
|
64
|
+
title: `${address.pageId} on ${DEVICE_FRAME.name} (${DEVICE_FRAME.width} × ${DEVICE_FRAME.height})`,
|
|
65
|
+
style: {
|
|
66
|
+
position: 'absolute',
|
|
67
|
+
top: 0,
|
|
68
|
+
left: 0,
|
|
69
|
+
width: DEVICE_FRAME.width,
|
|
70
|
+
height: DEVICE_FRAME.height,
|
|
71
|
+
border: 0,
|
|
72
|
+
transform: `scale(${scale})`,
|
|
73
|
+
transformOrigin: '0 0',
|
|
74
|
+
visibility: status === 'ready' ? 'visible' : 'hidden',
|
|
75
|
+
},
|
|
76
|
+
onLoad: () => setStatus('ready'),
|
|
77
|
+
onError: () => setStatus('unavailable'),
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
</View>
|
|
81
|
+
</View>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<View style={styles.measure} onLayout={onLayout}>
|
|
87
|
+
<View style={[styles.device, box]}>
|
|
88
|
+
<View style={[styles.screenInline, { transform: [{ scale }], transformOrigin: 'top left' } as object]}>{children}</View>
|
|
89
|
+
</View>
|
|
90
|
+
</View>
|
|
91
|
+
);
|
|
27
92
|
}
|
|
28
93
|
|
|
29
94
|
const styles = StyleSheet.create({
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
height: HEIGHT,
|
|
95
|
+
// Spans the cell so the device can be measured against it and centred in it.
|
|
96
|
+
measure: { width: '100%', alignItems: 'center' },
|
|
97
|
+
device: {
|
|
34
98
|
borderRadius: CATALOG_RADIUS.md,
|
|
35
99
|
borderWidth: StyleSheet.hairlineWidth,
|
|
36
100
|
borderColor: CATALOG_COLOR.border,
|
|
@@ -38,6 +102,26 @@ const styles = StyleSheet.create({
|
|
|
38
102
|
overflow: 'hidden',
|
|
39
103
|
alignItems: 'center',
|
|
40
104
|
justifyContent: 'center',
|
|
41
|
-
alignSelf: 'center',
|
|
42
105
|
},
|
|
106
|
+
screenInline: {
|
|
107
|
+
width: DEVICE_FRAME.width,
|
|
108
|
+
height: DEVICE_FRAME.height,
|
|
109
|
+
position: 'absolute',
|
|
110
|
+
top: 0,
|
|
111
|
+
left: 0,
|
|
112
|
+
alignItems: 'center',
|
|
113
|
+
justifyContent: 'center',
|
|
114
|
+
overflow: 'hidden',
|
|
115
|
+
},
|
|
116
|
+
// The child document's whole window is the device screen: exactly the device's height, never
|
|
117
|
+
// more, so a `flex: 1` scroll area inside a demo shrinks to fit and scrolls instead of growing.
|
|
118
|
+
screenFill: {
|
|
119
|
+
width: '100%',
|
|
120
|
+
height: DEVICE_FRAME.height,
|
|
121
|
+
backgroundColor: CATALOG_COLOR.surfacePressed,
|
|
122
|
+
alignItems: 'center',
|
|
123
|
+
justifyContent: 'center',
|
|
124
|
+
overflow: 'hidden',
|
|
125
|
+
},
|
|
126
|
+
status: { position: 'absolute', fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
|
|
43
127
|
});
|
|
@@ -86,10 +86,18 @@ export function ReferenceDetails<TId extends string>({ def }: { def: SectionDef<
|
|
|
86
86
|
if (next > 0 && next !== width) setWidth(next);
|
|
87
87
|
};
|
|
88
88
|
if (def.tokenGallery) {
|
|
89
|
+
// Full-width pages with no component API (token galleries, and whole screens such as a
|
|
90
|
+
// starter kit's Patterns) still disclose what they're built from when they say so.
|
|
89
91
|
return (
|
|
90
92
|
<View style={styles.card}>
|
|
91
93
|
<Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Quick reference</Text>
|
|
92
94
|
{def.path && <Fact label="Source" value={def.path} mono />}
|
|
95
|
+
{def.composedOf && def.composedOf.length > 0 && (
|
|
96
|
+
<>
|
|
97
|
+
<Text style={styles.subheading}>Composition</Text>
|
|
98
|
+
<CompositionFacts composedOf={def.composedOf} />
|
|
99
|
+
</>
|
|
100
|
+
)}
|
|
93
101
|
</View>
|
|
94
102
|
);
|
|
95
103
|
}
|
|
@@ -6,6 +6,7 @@ import { ComparisonGroups } from './ComparisonGroups';
|
|
|
6
6
|
import { ComparisonList } from './ComparisonList';
|
|
7
7
|
import { ReferenceDetails } from './ReferenceDetails';
|
|
8
8
|
import { SpecimenSurface } from './SpecimenSurface';
|
|
9
|
+
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
9
10
|
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
10
11
|
import { TokenSections } from './TokenLayouts';
|
|
11
12
|
import { MATRIX_LAYOUT, choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
|
|
@@ -169,15 +170,19 @@ function BlockContent<TId extends string>({ block, def, surface }: { block: Pres
|
|
|
169
170
|
case 'grid':
|
|
170
171
|
return <ComparisonGrid def={block.comparison} size={block.size} sectionId={def.id} surface={surface} />;
|
|
171
172
|
case 'grouped':
|
|
172
|
-
return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} surface={surface} />;
|
|
173
|
+
return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} surface={surface} pageId={def.id} />;
|
|
173
174
|
case 'list':
|
|
174
|
-
return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} maxColumns={block.maxColumns} surface={surface} />;
|
|
175
|
+
return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} maxColumns={block.maxColumns} surface={surface} pageId={def.id} />;
|
|
175
176
|
case 'preview':
|
|
176
177
|
// A token gallery's `preview` block shows full-width raw token data, not a component
|
|
177
178
|
// specimen — it keeps its current, unstaged presentation.
|
|
178
179
|
// `Preview`'s own `widths === 'full'` check already excludes a full-width or token-gallery
|
|
179
180
|
// preview from ever reaching the table layout, regardless of `def.previewLayout`.
|
|
180
|
-
return def.render ?
|
|
181
|
+
return def.render ? (
|
|
182
|
+
<SpecimenAddressProvider address={{ pageId: def.id, slot: 'preview', itemKey: 'preview' }}>
|
|
183
|
+
<Preview render={def.render} widths={block.widths} surface={def.tokenGallery ? undefined : surface} layout={def.previewLayout === 'table' ? 'table' : undefined} />
|
|
184
|
+
</SpecimenAddressProvider>
|
|
185
|
+
) : null;
|
|
181
186
|
case 'tokenSections':
|
|
182
187
|
return <TokenSections sections={block.sections} columns={block.columns} />;
|
|
183
188
|
default:
|
|
@@ -369,7 +374,8 @@ const styles = StyleSheet.create({
|
|
|
369
374
|
blocks: { gap: CATALOG_LAYOUT.blockGap, marginBottom: CATALOG_LAYOUT.blockGap },
|
|
370
375
|
blocksSide: { flexDirection: 'row', alignItems: 'flex-start' },
|
|
371
376
|
blockFill: { flex: 1, minWidth: 0 },
|
|
372
|
-
|
|
377
|
+
// Centred in the card, like every other example (a single phone-width frame used to sit left).
|
|
378
|
+
frames: { flexDirection: 'row', flexWrap: 'wrap', gap: CATALOG_SPACE.xl, alignItems: 'flex-start', justifyContent: 'center' },
|
|
373
379
|
frame: { maxWidth: '100%', gap: CATALOG_SPACE.sm },
|
|
374
380
|
frameLabel: {
|
|
375
381
|
fontSize: CATALOG_TYPE.tableHeader,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react';
|
|
2
|
+
import type { OverlayAddress } from './overlayViewport';
|
|
3
|
+
|
|
4
|
+
const SpecimenAddressContext = createContext<OverlayAddress | undefined>(undefined);
|
|
5
|
+
|
|
6
|
+
/** Tells the example rendered inside it which catalog example it is (page, slot, item key), so a
|
|
7
|
+
* `PhoneFrame` in it can open that one example in its own device-sized document. Set by the
|
|
8
|
+
* catalog's own list, grid and preview renderers, and by the child document itself. */
|
|
9
|
+
export function SpecimenAddressProvider({ address, children }: { address: OverlayAddress | undefined; children: React.ReactNode }) {
|
|
10
|
+
return <SpecimenAddressContext.Provider value={address}>{children}</SpecimenAddressContext.Provider>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useSpecimenAddress(): OverlayAddress | undefined {
|
|
14
|
+
return useContext(SpecimenAddressContext);
|
|
15
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
3
|
import { CATALOG_COLOR } from './tokens';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE, overflowsCell, paintedExtent, shouldCenterFilledContent, specimenContentAlignItems,
|
|
6
|
+
specimenContentBoundedStyle, visibleBoxes, type LayoutBox,
|
|
7
|
+
} from './specimenSurfaceStyle';
|
|
8
|
+
import { useSpecimenAddress } from './SpecimenAddress';
|
|
5
9
|
import { blendsIntoCell, measurePaintedBoxes } from './specimenFill';
|
|
6
10
|
import type { SpecimenAlign, SpecimenSurfaceKind } from './types';
|
|
7
11
|
|
|
@@ -75,12 +79,70 @@ export function SpecimenSurface({
|
|
|
75
79
|
* (e.g. Badge's `alignSelf: 'flex-start'`) free to override that positioning outright, since
|
|
76
80
|
* `alignItems` only sets a child's *default* `alignSelf`. `fill` keeps the existing column +
|
|
77
81
|
* `alignItems` stretch behavior, unchanged. */
|
|
82
|
+
type MeasurableNode = {
|
|
83
|
+
getBoundingClientRect: () => { left: number; right: number; width: number };
|
|
84
|
+
children: ArrayLike<MeasurableNode>;
|
|
85
|
+
childElementCount: number;
|
|
86
|
+
tagName: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** The example's element tree as `LayoutBox`es (up to 400 elements), so `visibleBoxes` can skip
|
|
90
|
+
* what scrolling or clipping containers hide. */
|
|
91
|
+
function readTree(root: MeasurableNode, browser: Browser): LayoutBox {
|
|
92
|
+
let budget = 400;
|
|
93
|
+
const read = (el: MeasurableNode): LayoutBox => {
|
|
94
|
+
const rect = el.getBoundingClientRect();
|
|
95
|
+
const style = browser.getComputedStyle!(el) as unknown as { backgroundColor: string; borderTopWidth: string; visibility: string; overflowX: string };
|
|
96
|
+
const kids: LayoutBox[] = [];
|
|
97
|
+
const children = el.children;
|
|
98
|
+
for (let i = 0; i < children.length && budget > 0; i++) {
|
|
99
|
+
budget -= 1;
|
|
100
|
+
const child = children[i];
|
|
101
|
+
if (child.getBoundingClientRect().width < 1) continue;
|
|
102
|
+
kids.push(read(child));
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
left: rect.left,
|
|
106
|
+
right: rect.right,
|
|
107
|
+
painted:
|
|
108
|
+
style.visibility !== 'hidden' &&
|
|
109
|
+
(el.childElementCount === 0 || el.tagName === 'svg' || el.tagName === 'IFRAME' ||
|
|
110
|
+
style.backgroundColor !== 'rgba(0, 0, 0, 0)' || parseFloat(style.borderTopWidth) > 0),
|
|
111
|
+
clips: style.overflowX === 'hidden' || style.overflowX === 'auto' || style.overflowX === 'scroll' || style.overflowX === 'clip',
|
|
112
|
+
children: kids,
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
return read(root);
|
|
116
|
+
}
|
|
117
|
+
|
|
78
118
|
export function SpecimenContent({ fill, align, children }: { fill?: boolean; align?: SpecimenAlign; children: React.ReactNode }) {
|
|
119
|
+
// After layout (browser only): a `fill` example whose visible content is narrower than the cell
|
|
120
|
+
// (a fixed-width skeleton, a tooltip around a small trigger) is centred instead of sitting
|
|
121
|
+
// against the leading edge; an explicit `align` always wins. And in development, an example
|
|
122
|
+
// wider than its cell logs a warning naming a wider `specimenSize`.
|
|
123
|
+
const address = useSpecimenAddress();
|
|
124
|
+
const ref = useRef<View>(null);
|
|
125
|
+
const [centerNarrow, setCenterNarrow] = useState(false);
|
|
126
|
+
const warned = useRef(false);
|
|
127
|
+
const measure = useCallback(() => {
|
|
128
|
+
const browser = globalThis as unknown as Browser;
|
|
129
|
+
const node = ref.current as unknown as MeasurableNode | null;
|
|
130
|
+
if (!browser.Element || !browser.getComputedStyle || !node?.getBoundingClientRect) return;
|
|
131
|
+
const wrapperWidth = node.getBoundingClientRect().width;
|
|
132
|
+
const visible = paintedExtent(visibleBoxes(readTree(node, browser)));
|
|
133
|
+
if (fill && align === undefined && !centerNarrow && shouldCenterFilledContent(visible, wrapperWidth)) setCenterNarrow(true);
|
|
134
|
+
if ((globalThis as { __DEV__?: boolean }).__DEV__ && !warned.current && overflowsCell(visible, wrapperWidth)) {
|
|
135
|
+
warned.current = true;
|
|
136
|
+
console.warn(
|
|
137
|
+
`[Catalog] ${address ? `${address.pageId} (${address.itemKey})` : 'An example'} is ${Math.round(visible)}px wide in a ${Math.round(wrapperWidth)}px cell, so it's cut off. Give the page a wider specimenSize ('regular' or 'wide').`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}, [fill, align, centerNarrow, address]);
|
|
79
141
|
if (fill) {
|
|
80
|
-
const alignItems = specimenContentAlignItems(fill, align);
|
|
81
|
-
return <View style={[SPECIMEN_SURFACE_WIDTH_STYLE, { alignItems }]}>{children}</View>;
|
|
142
|
+
const alignItems = centerNarrow ? 'center' : specimenContentAlignItems(fill, align);
|
|
143
|
+
return <View ref={ref} onLayout={measure} style={[SPECIMEN_SURFACE_WIDTH_STYLE, { alignItems }]}>{children}</View>;
|
|
82
144
|
}
|
|
83
|
-
return <View style={specimenContentBoundedStyle(align)}>{children}</View>;
|
|
145
|
+
return <View ref={ref} onLayout={measure} style={specimenContentBoundedStyle(align)}>{children}</View>;
|
|
84
146
|
}
|
|
85
147
|
|
|
86
148
|
// 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token); there is no dark-surface token.
|