@krapwoo/ds-viewer 0.4.6 → 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 +121 -21
- package/dist/cli/doctor.js +141 -2
- package/dist/cli/explain.js +44 -6
- package/dist/cli/init.js +5 -8
- package/dist/cli/props.js +80 -1
- package/dist/cli/staticPage.js +228 -6
- package/dist/native/catalog/comparison.js +43 -9
- package/native/catalog/BoundedOverlayViewport.tsx +112 -0
- package/native/catalog/CatalogShell.tsx +26 -0
- package/native/catalog/ComparisonGrid.tsx +6 -2
- package/native/catalog/ComparisonGroups.tsx +8 -2
- package/native/catalog/ComparisonList.tsx +8 -2
- package/native/catalog/PhoneFrame.tsx +109 -19
- package/native/catalog/ReferenceDetails.tsx +83 -20
- package/native/catalog/SectionBlock.tsx +75 -7
- package/native/catalog/SpecimenAddress.tsx +15 -0
- package/native/catalog/SpecimenSurface.tsx +80 -10
- package/native/catalog/comparison.ts +57 -10
- package/native/catalog/deviceFrame.ts +14 -0
- package/native/catalog/index.ts +4 -0
- package/native/catalog/overlayViewport.ts +158 -0
- package/native/catalog/pageApi.ts +6 -0
- package/native/catalog/specimenSurfaceStyle.ts +124 -1
- package/native/catalog/types.ts +42 -12
- 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 +5 -1
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +5 -1
- package/starter-kit/components/Button/Button.catalog.tsx +4 -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 +7 -1
- package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +7 -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 +6 -1
- package/starter-kit/components/List/List.catalog.tsx +5 -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 +4 -1
- package/starter-kit/components/PillRow/PillRow.catalog.tsx +4 -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 +6 -1
- package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +5 -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 +5 -1
- package/starter-kit/components/Toast/Toast.catalog.tsx +9 -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
|
@@ -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
|
}) {
|
|
@@ -61,8 +65,10 @@ export function ComparisonList({
|
|
|
61
65
|
<View style={styles.caption}>
|
|
62
66
|
<Text style={styles.captionText}>{item.label}</Text>
|
|
63
67
|
</View>
|
|
64
|
-
<SpecimenSurface surface={surface} style={styles.specimen}>
|
|
65
|
-
<
|
|
68
|
+
<SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
|
|
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,31 +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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* A real phone screen for a live demo: a screen, a sheet, a dialog, a toast sliding in.
|
|
14
|
+
*
|
|
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.
|
|
15
25
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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.
|
|
19
29
|
*/
|
|
20
30
|
export function PhoneFrame({ children }: { children: React.ReactNode }) {
|
|
21
|
-
|
|
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
|
+
);
|
|
22
92
|
}
|
|
23
93
|
|
|
24
94
|
const styles = StyleSheet.create({
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
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: {
|
|
29
98
|
borderRadius: CATALOG_RADIUS.md,
|
|
30
99
|
borderWidth: StyleSheet.hairlineWidth,
|
|
31
100
|
borderColor: CATALOG_COLOR.border,
|
|
@@ -34,4 +103,25 @@ const styles = StyleSheet.create({
|
|
|
34
103
|
alignItems: 'center',
|
|
35
104
|
justifyContent: 'center',
|
|
36
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 },
|
|
37
127
|
});
|
|
@@ -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,42 +40,98 @@ 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) {
|
|
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.
|
|
51
91
|
return (
|
|
52
92
|
<View style={styles.card}>
|
|
53
93
|
<Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Quick reference</Text>
|
|
54
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
|
+
)}
|
|
55
101
|
</View>
|
|
56
102
|
);
|
|
57
103
|
}
|
|
104
|
+
const hasComposition = Boolean(def.composedOf && def.composedOf.length > 0);
|
|
105
|
+
const columns = referenceColumns(width, hasComposition);
|
|
106
|
+
const stacked = columns === 1;
|
|
107
|
+
const compositionIsOwnColumn = columns === 3 && hasComposition;
|
|
108
|
+
const columnStyle = stacked ? styles.columnStacked : styles.column;
|
|
109
|
+
|
|
58
110
|
return (
|
|
59
111
|
<View style={styles.stack}>
|
|
60
112
|
<View style={styles.card}>
|
|
61
|
-
<View style={styles.columns}>
|
|
62
|
-
<View style={
|
|
113
|
+
<View style={[styles.columns, stacked && styles.columnsStacked]} onLayout={onLayout}>
|
|
114
|
+
<View style={columnStyle}>
|
|
63
115
|
<Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Guidance</Text>
|
|
64
116
|
<Text style={styles.body}>{def.whenToUse ?? 'No usage guidance documented.'}</Text>
|
|
65
117
|
</View>
|
|
66
|
-
<View style={
|
|
118
|
+
<View style={columnStyle}>
|
|
67
119
|
<Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Quick reference</Text>
|
|
68
120
|
{def.path && <Fact label="Source" value={def.path} mono />}
|
|
69
121
|
{!hide.accessibility && <Fact label="Accessibility" value={def.a11y ?? 'No accessibility notes documented.'} />}
|
|
70
|
-
{
|
|
122
|
+
{!compositionIsOwnColumn && hasComposition && def.composedOf && (
|
|
71
123
|
<>
|
|
72
124
|
<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
|
-
))}
|
|
125
|
+
<CompositionFacts composedOf={def.composedOf} />
|
|
76
126
|
</>
|
|
77
127
|
)}
|
|
78
128
|
</View>
|
|
129
|
+
{compositionIsOwnColumn && def.composedOf && (
|
|
130
|
+
<View style={columnStyle}>
|
|
131
|
+
<Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Composition</Text>
|
|
132
|
+
<CompositionFacts composedOf={def.composedOf} />
|
|
133
|
+
</View>
|
|
134
|
+
)}
|
|
79
135
|
</View>
|
|
80
136
|
</View>
|
|
81
137
|
{!hide.props && <PropsBox def={def} />}
|
|
@@ -93,7 +149,9 @@ const styles = StyleSheet.create({
|
|
|
93
149
|
padding: CATALOG_LAYOUT.panelPadding,
|
|
94
150
|
},
|
|
95
151
|
columns: { flexDirection: 'row', gap: CATALOG_SPACE['2xl'] },
|
|
96
|
-
|
|
152
|
+
columnsStacked: { flexDirection: 'column' },
|
|
153
|
+
column: { flex: 1, minWidth: 0 },
|
|
154
|
+
columnStacked: { minWidth: 0 },
|
|
97
155
|
heading: {
|
|
98
156
|
fontSize: CATALOG_TYPE.panelHeading,
|
|
99
157
|
fontWeight: '700',
|
|
@@ -111,15 +169,20 @@ const styles = StyleSheet.create({
|
|
|
111
169
|
marginBottom: CATALOG_SPACE.xs,
|
|
112
170
|
},
|
|
113
171
|
fact: {
|
|
114
|
-
flexDirection: '
|
|
115
|
-
|
|
116
|
-
gap: CATALOG_SPACE.lg,
|
|
172
|
+
flexDirection: 'column',
|
|
173
|
+
gap: CATALOG_SPACE.xs,
|
|
117
174
|
paddingVertical: CATALOG_LAYOUT.factPaddingY,
|
|
118
175
|
borderBottomWidth: 1,
|
|
119
176
|
borderBottomColor: CATALOG_COLOR.borderSubtle,
|
|
120
177
|
},
|
|
121
|
-
factLabel: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
|
|
122
|
-
factValue: {
|
|
123
|
-
|
|
178
|
+
factLabel: { flexShrink: 1, minWidth: 0, fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
|
|
179
|
+
factValue: { minWidth: 0, fontSize: CATALOG_TYPE.md, lineHeight: 20, textAlign: 'left', color: CATALOG_COLOR.textMuted },
|
|
180
|
+
// `wordBreak` isn't in the installed `TextStyle` type (RN's own, not RNW's — RNW's own
|
|
181
|
+
// `Text/types.js` declares it; a browser applies any valid CSS property name it recognizes
|
|
182
|
+
// regardless), so a bare literal here would report an unknown property under `--noEmit` even
|
|
183
|
+
// though it genuinely works: a long unbroken token (a source path with no spaces, e.g.
|
|
184
|
+
// `AnimatedChevron.tsx`) has no space for the browser's default line-breaking to use, so without
|
|
185
|
+
// this it stays one unbroken run and overflows a narrow card instead of wrapping at its '/'s.
|
|
186
|
+
mono: { fontFamily: CATALOG_COLOR.code, wordBreak: 'break-word' } as TextStyle,
|
|
124
187
|
empty: { fontSize: CATALOG_TYPE.sm, fontStyle: 'italic', color: CATALOG_COLOR.textMuted },
|
|
125
188
|
});
|
|
@@ -6,9 +6,10 @@ 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
|
-
import { choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
|
|
12
|
+
import { MATRIX_LAYOUT, choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
|
|
12
13
|
import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
|
|
13
14
|
|
|
14
15
|
// Matches a quoted-string-literal union type, e.g. "'primary' | 'secondary' | 'tertiary'" — anything
|
|
@@ -103,11 +104,14 @@ function frameLabel(width: number): string {
|
|
|
103
104
|
|
|
104
105
|
/** A component preview: one frame per width (each at most phone width), or full width for catalog
|
|
105
106
|
* chrome and token galleries. Every frame is its own live instance with its own state. `surface`
|
|
106
|
-
* is omitted for token galleries, which keep their current, unstaged presentation.
|
|
107
|
-
|
|
107
|
+
* is omitted for token galleries, which keep their current, unstaged presentation. `layout: 'table'`
|
|
108
|
+
* (only ever set for a numeric-width preview — see `presentationBlocks`) renders `TablePreview`
|
|
109
|
+
* instead; omitted/`'frames'` keeps this exact, unchanged presentation. */
|
|
110
|
+
function Preview({ render, widths, surface, layout }: { render: () => React.ReactNode; widths: PreviewWidths; surface?: SpecimenSurfaceKind; layout?: 'table' }) {
|
|
108
111
|
// Token galleries pass no surface: their card stays white.
|
|
109
112
|
const kind = surface ?? 'transparent';
|
|
110
113
|
if (widths === 'full') return <SpecimenSurface surface={kind} style={styles.previewCard}>{render()}</SpecimenSurface>;
|
|
114
|
+
if (layout === 'table') return <TablePreview render={render} widths={widths} surface={kind} />;
|
|
111
115
|
return (
|
|
112
116
|
<View style={styles.previewCard}>
|
|
113
117
|
<View style={styles.frames}>
|
|
@@ -128,18 +132,57 @@ function Preview({ render, widths, surface }: { render: () => React.ReactNode; w
|
|
|
128
132
|
);
|
|
129
133
|
}
|
|
130
134
|
|
|
135
|
+
/** `layout: 'table'` presentation for a numeric-width Preview: every width shares ONE
|
|
136
|
+
* `ComparisonList`-style card (same caption typography, muted caption strip, border/radius/surface
|
|
137
|
+
* tokens) instead of each width getting its own bordered frame-card. Each width keeps its own
|
|
138
|
+
* caption strip directly above its own live body, in a single flex-wrapped row — one stable parent
|
|
139
|
+
* with keyed cells, so a viewport resize that wraps the row to stacked columns reflows in place
|
|
140
|
+
* rather than remounting any demo. A cell's body carries the same `MATRIX_LAYOUT.cellPadding`
|
|
141
|
+
* `ComparisonList`'s own cells use; the requested width instead reaches the demo untouched through
|
|
142
|
+
* an inner width-qualified viewport nested inside that padded body (capped only by
|
|
143
|
+
* `maxWidth: '100%'`, the same cap `Preview`'s own frames already use), so a cell's `flexBasis`
|
|
144
|
+
* reserving `width + 2 * cellPadding` leaves the padded body exactly enough room for it, with no
|
|
145
|
+
* blank trailing slack. The row's cells share 1px gutters (a `gap` on a bordered backing) for
|
|
146
|
+
* dividers, in both the side-by-side and stacked-wrap arrangements. */
|
|
147
|
+
function TablePreview({ render, widths, surface }: { render: () => React.ReactNode; widths: readonly number[]; surface: SpecimenSurfaceKind }) {
|
|
148
|
+
return (
|
|
149
|
+
<View style={styles.tableCard}>
|
|
150
|
+
<View style={styles.tableRow}>
|
|
151
|
+
{widths.map((width, i) => (
|
|
152
|
+
<View key={`${width}-${i}`} style={[styles.tableCell, { flexBasis: width + 2 * MATRIX_LAYOUT.cellPadding }]}>
|
|
153
|
+
<View style={styles.tableCaption}>
|
|
154
|
+
<Text style={styles.tableCaptionText}>{frameLabel(width)}</Text>
|
|
155
|
+
</View>
|
|
156
|
+
<SpecimenSurface surface={surface} style={styles.tableBody}>
|
|
157
|
+
<View role="group" aria-label={frameLabel(width)} style={[styles.tableBodyViewport, { width }]}>
|
|
158
|
+
{render()}
|
|
159
|
+
</View>
|
|
160
|
+
</SpecimenSurface>
|
|
161
|
+
</View>
|
|
162
|
+
))}
|
|
163
|
+
</View>
|
|
164
|
+
</View>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
131
168
|
function BlockContent<TId extends string>({ block, def, surface }: { block: PresentationBlock; def: SectionDef<TId>; surface: SpecimenSurfaceKind }) {
|
|
132
169
|
switch (block.kind) {
|
|
133
170
|
case 'grid':
|
|
134
171
|
return <ComparisonGrid def={block.comparison} size={block.size} sectionId={def.id} surface={surface} />;
|
|
135
172
|
case 'grouped':
|
|
136
|
-
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} />;
|
|
137
174
|
case 'list':
|
|
138
|
-
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} />;
|
|
139
176
|
case 'preview':
|
|
140
177
|
// A token gallery's `preview` block shows full-width raw token data, not a component
|
|
141
178
|
// specimen — it keeps its current, unstaged presentation.
|
|
142
|
-
|
|
179
|
+
// `Preview`'s own `widths === 'full'` check already excludes a full-width or token-gallery
|
|
180
|
+
// preview from ever reaching the table layout, regardless of `def.previewLayout`.
|
|
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;
|
|
143
186
|
case 'tokenSections':
|
|
144
187
|
return <TokenSections sections={block.sections} columns={block.columns} />;
|
|
145
188
|
default:
|
|
@@ -331,7 +374,8 @@ const styles = StyleSheet.create({
|
|
|
331
374
|
blocks: { gap: CATALOG_LAYOUT.blockGap, marginBottom: CATALOG_LAYOUT.blockGap },
|
|
332
375
|
blocksSide: { flexDirection: 'row', alignItems: 'flex-start' },
|
|
333
376
|
blockFill: { flex: 1, minWidth: 0 },
|
|
334
|
-
|
|
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' },
|
|
335
379
|
frame: { maxWidth: '100%', gap: CATALOG_SPACE.sm },
|
|
336
380
|
frameLabel: {
|
|
337
381
|
fontSize: CATALOG_TYPE.tableHeader,
|
|
@@ -356,5 +400,29 @@ const styles = StyleSheet.create({
|
|
|
356
400
|
padding: CATALOG_SPACE.xl,
|
|
357
401
|
gap: CATALOG_SPACE.md,
|
|
358
402
|
},
|
|
403
|
+
// `ComparisonList`'s own card/caption tokens, reused so a 'table' preview reads as the same shared
|
|
404
|
+
// surface — no outer `previewCard` padding/chrome around it (that card provides its own).
|
|
405
|
+
tableCard: {
|
|
406
|
+
backgroundColor: CATALOG_COLOR.surface,
|
|
407
|
+
borderWidth: 1,
|
|
408
|
+
borderColor: CATALOG_COLOR.borderStrong,
|
|
409
|
+
borderRadius: CATALOG_RADIUS.card,
|
|
410
|
+
overflow: 'hidden',
|
|
411
|
+
},
|
|
412
|
+
// 1px gutters between cells: a gap over a bordered backing, so dividers read correctly whether
|
|
413
|
+
// cells sit side by side or wrap into stacked rows.
|
|
414
|
+
tableRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 1, backgroundColor: CATALOG_COLOR.border },
|
|
415
|
+
tableCell: { backgroundColor: CATALOG_COLOR.surface, flexGrow: 1, minWidth: 0, maxWidth: '100%' },
|
|
416
|
+
tableCaption: {
|
|
417
|
+
padding: MATRIX_LAYOUT.cellPadding,
|
|
418
|
+
backgroundColor: CATALOG_COLOR.surfaceMuted,
|
|
419
|
+
borderBottomWidth: 1,
|
|
420
|
+
borderBottomColor: CATALOG_COLOR.border,
|
|
421
|
+
},
|
|
422
|
+
tableCaptionText: { fontSize: CATALOG_TYPE.tableHeader, fontWeight: '800', letterSpacing: 0.44, color: CATALOG_COLOR.text },
|
|
423
|
+
// Same cell padding `ComparisonList` uses: the requested width instead reaches the live demo
|
|
424
|
+
// untouched through `tableBodyViewport`, the inner width-qualified group nested inside this body.
|
|
425
|
+
tableBody: { padding: MATRIX_LAYOUT.cellPadding, flexGrow: 1, alignItems: 'center', justifyContent: 'flex-start' },
|
|
426
|
+
tableBodyViewport: { maxWidth: '100%' },
|
|
359
427
|
emptyText: { fontSize: CATALOG_TYPE.sm, fontStyle: 'italic', color: CATALOG_COLOR.textMuted },
|
|
360
428
|
});
|
|
@@ -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,9 +1,13 @@
|
|
|
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
|
-
import type { SpecimenSurfaceKind } from './types';
|
|
10
|
+
import type { SpecimenAlign, SpecimenSurfaceKind } from './types';
|
|
7
11
|
|
|
8
12
|
type Browser = {
|
|
9
13
|
getComputedStyle?: (element: unknown) => never;
|
|
@@ -65,9 +69,80 @@ export function SpecimenSurface({
|
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
/** Wraps one example inside its cell: full cell width (so a percentage-width example resolves
|
|
68
|
-
* against the cell)
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
* against the cell). `fill` changes whether the example itself also stretches to that width
|
|
73
|
+
* (default) or keeps its own content size — a non-`fill` specimen centers by default; a `fill`
|
|
74
|
+
* one stretches by default instead, preserving an auto-sized full-width child (see
|
|
75
|
+
* `specimenContentAlignItems`). `align` always overrides that default explicitly.
|
|
76
|
+
*
|
|
77
|
+
* A non-`fill` specimen uses a row main axis (`specimenContentBoundedStyle`) so `justifyContent`
|
|
78
|
+
* positions it horizontally — a column + `alignItems` left the specimen's own root `alignSelf`
|
|
79
|
+
* (e.g. Badge's `alignSelf: 'flex-start'`) free to override that positioning outright, since
|
|
80
|
+
* `alignItems` only sets a child's *default* `alignSelf`. `fill` keeps the existing column +
|
|
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
|
+
|
|
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]);
|
|
141
|
+
if (fill) {
|
|
142
|
+
const alignItems = centerNarrow ? 'center' : specimenContentAlignItems(fill, align);
|
|
143
|
+
return <View ref={ref} onLayout={measure} style={[SPECIMEN_SURFACE_WIDTH_STYLE, { alignItems }]}>{children}</View>;
|
|
144
|
+
}
|
|
145
|
+
return <View ref={ref} onLayout={measure} style={specimenContentBoundedStyle(align)}>{children}</View>;
|
|
71
146
|
}
|
|
72
147
|
|
|
73
148
|
// 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token); there is no dark-surface token.
|
|
@@ -77,8 +152,3 @@ const FILL = StyleSheet.create({
|
|
|
77
152
|
dark: { backgroundColor: CATALOG_COLOR.text },
|
|
78
153
|
transparent: {},
|
|
79
154
|
});
|
|
80
|
-
|
|
81
|
-
const styles = StyleSheet.create({
|
|
82
|
-
center: { ...SPECIMEN_SURFACE_WIDTH_STYLE, alignItems: 'center' },
|
|
83
|
-
fill: { ...SPECIMEN_SURFACE_WIDTH_STYLE },
|
|
84
|
-
});
|