@krapwoo/ds-viewer 0.4.2 → 0.4.4
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 -1
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/explain.js +29 -8
- package/dist/cli/props.js +20 -5
- package/dist/cli/staticPage.js +42 -6
- package/dist/cli/sync.js +2 -1
- package/dist/native/catalog/catalogNavigation.js +4 -2
- package/dist/native/catalog/comparison.js +22 -6
- package/dist/native/catalog/tokenLayout.js +26 -0
- package/native/catalog/CatalogShell.tsx +3 -1
- package/native/catalog/CatalogSidebar.tsx +7 -4
- package/native/catalog/ComparisonGrid.tsx +18 -5
- package/native/catalog/ComparisonGroups.tsx +18 -5
- package/native/catalog/ComparisonList.tsx +22 -6
- package/native/catalog/MotionSpecimen.tsx +152 -0
- package/native/catalog/ReferenceDetails.tsx +15 -0
- package/native/catalog/SectionBlock.tsx +50 -21
- package/native/catalog/SpecimenSurface.tsx +46 -0
- package/native/catalog/TokenLayouts.tsx +92 -0
- package/native/catalog/TokenRow.tsx +30 -5
- package/native/catalog/catalogNavigation.ts +7 -2
- package/native/catalog/comparison.ts +28 -9
- package/native/catalog/index.ts +8 -1
- package/native/catalog/motionValueDomain.ts +37 -0
- package/native/catalog/pageApi.ts +38 -3
- package/native/catalog/specimenSurfaceStyle.ts +15 -0
- package/native/catalog/tokenLayout.ts +29 -0
- package/native/catalog/types.ts +62 -0
- package/package.json +6 -1
- package/starter-kit/pages/Motion.catalog.tsx +48 -20
|
@@ -2,15 +2,27 @@ import React from 'react';
|
|
|
2
2
|
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
|
-
import
|
|
5
|
+
import { SpecimenSurface } from './SpecimenSurface';
|
|
6
|
+
import type { SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Grouped rows: one row per variant, headed by the variant's name, holding that variant's own
|
|
9
10
|
* configurations. Each cell keeps its own caption because the rows do not share column meanings
|
|
10
11
|
* (a circle's size is not a bar's thickness). Each row is its own labeled list. Shorter rows end
|
|
11
|
-
* in blank cells; a card wider than its container scrolls horizontally inside itself.
|
|
12
|
+
* in blank cells; a card wider than its container scrolls horizontally inside itself. `surface` is
|
|
13
|
+
* the page's `specimenSurface` behind each item.
|
|
12
14
|
*/
|
|
13
|
-
export function ComparisonGroups({
|
|
15
|
+
export function ComparisonGroups({
|
|
16
|
+
groups,
|
|
17
|
+
size,
|
|
18
|
+
label,
|
|
19
|
+
surface = 'neutral',
|
|
20
|
+
}: {
|
|
21
|
+
groups: ListGroup[];
|
|
22
|
+
size: SpecimenSize;
|
|
23
|
+
label: string;
|
|
24
|
+
surface?: SpecimenSurfaceKind;
|
|
25
|
+
}) {
|
|
14
26
|
const columns = Math.max(1, ...groups.map((g) => g.items.length));
|
|
15
27
|
const min = COLUMN_MIN_WIDTH[size];
|
|
16
28
|
const minWidth = MATRIX_LAYOUT.rowHeaderWidth + columns * min + 2;
|
|
@@ -35,7 +47,9 @@ export function ComparisonGroups({ groups, size, label }: { groups: ListGroup[];
|
|
|
35
47
|
<Text style={styles.captionText}>{item.label}</Text>
|
|
36
48
|
</View>
|
|
37
49
|
<View style={styles.specimen}>
|
|
38
|
-
<
|
|
50
|
+
<SpecimenSurface surface={surface} style={item.fill && styles.fill}>
|
|
51
|
+
{item.node as React.ReactNode}
|
|
52
|
+
</SpecimenSurface>
|
|
39
53
|
</View>
|
|
40
54
|
</View>
|
|
41
55
|
);
|
|
@@ -88,6 +102,5 @@ const styles = StyleSheet.create({
|
|
|
88
102
|
alignItems: 'center',
|
|
89
103
|
justifyContent: 'center',
|
|
90
104
|
},
|
|
91
|
-
center: { alignItems: 'center' },
|
|
92
105
|
fill: { alignSelf: 'stretch' },
|
|
93
106
|
});
|
|
@@ -2,16 +2,31 @@ import React, { useState } from 'react';
|
|
|
2
2
|
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
|
-
import
|
|
5
|
+
import { SpecimenSurface } from './SpecimenSurface';
|
|
6
|
+
import type { SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* One-axis examples inside ONE shared card. Cells wrap into balanced rows (listGeometry); each cell
|
|
9
10
|
* has its own caption strip so labels stay attached when rows wrap. Blank cells complete an uneven
|
|
10
|
-
* last row. Wide specimens use fixed 402px cells and the card hugs its columns.
|
|
11
|
+
* last row. Wide specimens use fixed 402px cells and the card hugs its columns. `maxColumns` caps
|
|
12
|
+
* how many columns a row ever wraps into (from `VariantSlot.maxColumns`); `surface` is the page's
|
|
13
|
+
* `specimenSurface` behind each item.
|
|
11
14
|
*/
|
|
12
|
-
export function ComparisonList({
|
|
15
|
+
export function ComparisonList({
|
|
16
|
+
items,
|
|
17
|
+
size,
|
|
18
|
+
label,
|
|
19
|
+
maxColumns,
|
|
20
|
+
surface = 'neutral',
|
|
21
|
+
}: {
|
|
22
|
+
items: ListItem[];
|
|
23
|
+
size: SpecimenSize;
|
|
24
|
+
label: string;
|
|
25
|
+
maxColumns?: 1 | 2 | 3 | 4 | 5;
|
|
26
|
+
surface?: SpecimenSurfaceKind;
|
|
27
|
+
}) {
|
|
13
28
|
const [available, setAvailable] = useState<number>(MATRIX_LAYOUT.laptopContentWidth);
|
|
14
|
-
const geometry = listGeometry(items.length, available, size);
|
|
29
|
+
const geometry = listGeometry(items.length, available, size, maxColumns);
|
|
15
30
|
const onLayout = (event: LayoutChangeEvent) => {
|
|
16
31
|
const width = Math.round(event.nativeEvent.layout.width);
|
|
17
32
|
if (width > 0 && width !== available) setAvailable(width);
|
|
@@ -46,7 +61,9 @@ export function ComparisonList({ items, size, label }: { items: ListItem[]; size
|
|
|
46
61
|
<Text style={styles.captionText}>{item.label}</Text>
|
|
47
62
|
</View>
|
|
48
63
|
<View style={styles.specimen}>
|
|
49
|
-
<
|
|
64
|
+
<SpecimenSurface surface={surface} style={item.fill && styles.fill}>
|
|
65
|
+
{item.node as React.ReactNode}
|
|
66
|
+
</SpecimenSurface>
|
|
50
67
|
</View>
|
|
51
68
|
</View>
|
|
52
69
|
);
|
|
@@ -84,6 +101,5 @@ const styles = StyleSheet.create({
|
|
|
84
101
|
alignItems: 'center',
|
|
85
102
|
justifyContent: 'center',
|
|
86
103
|
},
|
|
87
|
-
center: { alignItems: 'center' },
|
|
88
104
|
fill: { alignSelf: 'stretch' },
|
|
89
105
|
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Animated, AccessibilityInfo, Pressable, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
|
|
4
|
+
import { motionReplayLabel, motionTranslatesDirectly, motionValueTarget, type MotionValueRange } from './motionValueDomain';
|
|
5
|
+
|
|
6
|
+
/** `Animated.spring`'s physics fields — the same shape as a design system's own spring-config
|
|
7
|
+
* token (e.g. `DS_MOTION_SPRING`), kept local here rather than imported from any app's tokens so
|
|
8
|
+
* this stays an app-agnostic catalog piece. */
|
|
9
|
+
export interface MotionSpecimenSpringConfig {
|
|
10
|
+
stiffness?: number;
|
|
11
|
+
damping?: number;
|
|
12
|
+
mass?: number;
|
|
13
|
+
overshootClamping?: boolean;
|
|
14
|
+
restDisplacementThreshold?: number;
|
|
15
|
+
restSpeedThreshold?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type MotionSpecimenProps =
|
|
19
|
+
| {
|
|
20
|
+
kind: 'timing';
|
|
21
|
+
/** Duration in ms, e.g. a page's own `DS_MOTION_DURATION.base`. */
|
|
22
|
+
duration: number;
|
|
23
|
+
/** A real `Easing` function, e.g. `Easing.bezier(...DS_MOTION_EASING.standard)`. Defaults to
|
|
24
|
+
* `Animated.timing`'s own ease-in-out when omitted; pass `Easing.linear` for linear. */
|
|
25
|
+
easing?: (value: number) => number;
|
|
26
|
+
/** Track length in px the dot travels, start to end. @default 160 */
|
|
27
|
+
distance?: number;
|
|
28
|
+
label?: string;
|
|
29
|
+
}
|
|
30
|
+
| {
|
|
31
|
+
kind: 'spring';
|
|
32
|
+
spring: MotionSpecimenSpringConfig;
|
|
33
|
+
/** Which domain `spring`'s rest thresholds are scaled for (see `motionValueDomain.ts`):
|
|
34
|
+
* `'distance'` (default) for a config tuned for pixel-space points, e.g. a `BottomSheet`'s
|
|
35
|
+
* snap-point spring; `'unit'` for a config tuned for a normalized 0-to-1 progress/blend
|
|
36
|
+
* value — `'distance'`'s pixel-scale thresholds would otherwise end a unit-valued spring
|
|
37
|
+
* before it ever moves. Does not change the track's own pixel length (`distance`), only
|
|
38
|
+
* which domain the `Animated.Value` itself animates in. */
|
|
39
|
+
valueRange?: MotionValueRange;
|
|
40
|
+
distance?: number;
|
|
41
|
+
label?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const DOT_SIZE = 20;
|
|
45
|
+
const DEFAULT_DISTANCE = 160;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A reusable, bounded start-to-end motion demo for a duration/easing or spring token: a dot slides
|
|
49
|
+
* once across a fixed track when the specimen mounts, and a "Replay" button plays it again on
|
|
50
|
+
* demand. Respects the platform's reduce-motion preference — when enabled, every play (including
|
|
51
|
+
* the initial one) jumps straight to the end state instead of animating, exactly as the real
|
|
52
|
+
* product components built on these tokens should. Uses only `Animated`/`AccessibilityInfo` from
|
|
53
|
+
* `react-native`, already a peer dependency — no new runtime dependency.
|
|
54
|
+
*/
|
|
55
|
+
export function MotionSpecimen(props: MotionSpecimenProps) {
|
|
56
|
+
const { label, distance = DEFAULT_DISTANCE } = props;
|
|
57
|
+
const valueRange: MotionValueRange = props.kind === 'spring' ? props.valueRange ?? 'distance' : 'distance';
|
|
58
|
+
const target = motionValueTarget(valueRange, distance);
|
|
59
|
+
const progress = useRef(new Animated.Value(0)).current;
|
|
60
|
+
const activeAnim = useRef<Animated.CompositeAnimation | null>(null);
|
|
61
|
+
const [reduceMotion, setReduceMotion] = useState(false);
|
|
62
|
+
|
|
63
|
+
const play = useCallback(
|
|
64
|
+
(instant: boolean) => {
|
|
65
|
+
activeAnim.current?.stop();
|
|
66
|
+
progress.setValue(0);
|
|
67
|
+
if (instant) {
|
|
68
|
+
progress.setValue(target);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const anim =
|
|
72
|
+
props.kind === 'timing'
|
|
73
|
+
? Animated.timing(progress, { toValue: target, duration: props.duration, easing: props.easing, useNativeDriver: true })
|
|
74
|
+
: Animated.spring(progress, { toValue: target, useNativeDriver: true, ...props.spring });
|
|
75
|
+
activeAnim.current = anim;
|
|
76
|
+
anim.start();
|
|
77
|
+
},
|
|
78
|
+
// `props` covers every field `play` reads (duration/easing/spring differ per kind); `target`
|
|
79
|
+
// is derived from `props` (distance, kind, valueRange), not independent state.
|
|
80
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
81
|
+
[progress, props, target],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
let mounted = true;
|
|
86
|
+
AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
|
|
87
|
+
if (!mounted) return;
|
|
88
|
+
setReduceMotion(enabled);
|
|
89
|
+
play(enabled);
|
|
90
|
+
});
|
|
91
|
+
const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', (enabled: boolean) => setReduceMotion(enabled));
|
|
92
|
+
return () => {
|
|
93
|
+
mounted = false;
|
|
94
|
+
subscription.remove();
|
|
95
|
+
activeAnim.current?.stop();
|
|
96
|
+
};
|
|
97
|
+
// Plays exactly once on mount; `play` only needs to be re-created, never re-run, when props change.
|
|
98
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const translateX = motionTranslatesDirectly(valueRange)
|
|
102
|
+
? progress
|
|
103
|
+
: progress.interpolate({ inputRange: [0, target], outputRange: [0, distance] });
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<View style={styles.wrap}>
|
|
107
|
+
<View style={[styles.track, { width: distance + DOT_SIZE }]}>
|
|
108
|
+
<Animated.View style={[styles.dot, { transform: [{ translateX }] }]} />
|
|
109
|
+
</View>
|
|
110
|
+
<View style={styles.footer}>
|
|
111
|
+
{label ? <Text style={styles.label}>{label}</Text> : <View style={styles.labelSpacer} />}
|
|
112
|
+
<Pressable
|
|
113
|
+
onPress={() => play(reduceMotion)}
|
|
114
|
+
accessibilityRole="button"
|
|
115
|
+
accessibilityLabel={motionReplayLabel({ kind: props.kind, duration: props.kind === 'timing' ? props.duration : undefined, label })}
|
|
116
|
+
style={({ pressed }) => [styles.replayButton, pressed && styles.replayButtonPressed]}
|
|
117
|
+
>
|
|
118
|
+
<Text style={styles.replayText}>Replay</Text>
|
|
119
|
+
</Pressable>
|
|
120
|
+
</View>
|
|
121
|
+
</View>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const styles = StyleSheet.create({
|
|
126
|
+
wrap: { gap: CATALOG_SPACE.sm, alignItems: 'flex-start' },
|
|
127
|
+
track: {
|
|
128
|
+
height: DOT_SIZE,
|
|
129
|
+
borderRadius: DOT_SIZE / 2,
|
|
130
|
+
backgroundColor: CATALOG_COLOR.border,
|
|
131
|
+
justifyContent: 'center',
|
|
132
|
+
},
|
|
133
|
+
dot: {
|
|
134
|
+
width: DOT_SIZE,
|
|
135
|
+
height: DOT_SIZE,
|
|
136
|
+
borderRadius: DOT_SIZE / 2,
|
|
137
|
+
backgroundColor: CATALOG_COLOR.accent,
|
|
138
|
+
},
|
|
139
|
+
footer: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md },
|
|
140
|
+
label: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
|
|
141
|
+
labelSpacer: { flex: 1 },
|
|
142
|
+
replayButton: {
|
|
143
|
+
paddingHorizontal: CATALOG_SPACE.md,
|
|
144
|
+
paddingVertical: CATALOG_SPACE.xs,
|
|
145
|
+
borderRadius: CATALOG_RADIUS.control,
|
|
146
|
+
borderWidth: 1,
|
|
147
|
+
borderColor: CATALOG_COLOR.borderStrong,
|
|
148
|
+
backgroundColor: CATALOG_COLOR.surface,
|
|
149
|
+
},
|
|
150
|
+
replayButtonPressed: { backgroundColor: CATALOG_COLOR.surfacePressed },
|
|
151
|
+
replayText: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', color: CATALOG_COLOR.text },
|
|
152
|
+
});
|
|
@@ -67,6 +67,14 @@ export function ReferenceDetails<TId extends string>({ def }: { def: SectionDef<
|
|
|
67
67
|
<Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Quick reference</Text>
|
|
68
68
|
{def.path && <Fact label="Source" value={def.path} mono />}
|
|
69
69
|
{!hide.accessibility && <Fact label="Accessibility" value={def.a11y ?? 'No accessibility notes documented.'} />}
|
|
70
|
+
{def.composedOf && def.composedOf.length > 0 && (
|
|
71
|
+
<>
|
|
72
|
+
<Text style={styles.subheading}>Composition</Text>
|
|
73
|
+
{def.composedOf.map((entry, i) => (
|
|
74
|
+
<Fact key={`${entry.component}-${entry.relationship}-${i}`} label={`${entry.component} · ${entry.relationship}`} value={entry.role} />
|
|
75
|
+
))}
|
|
76
|
+
</>
|
|
77
|
+
)}
|
|
70
78
|
</View>
|
|
71
79
|
</View>
|
|
72
80
|
</View>
|
|
@@ -95,6 +103,13 @@ const styles = StyleSheet.create({
|
|
|
95
103
|
marginBottom: 10,
|
|
96
104
|
},
|
|
97
105
|
body: { fontSize: CATALOG_TYPE.md, lineHeight: 20, color: CATALOG_COLOR.textMuted },
|
|
106
|
+
subheading: {
|
|
107
|
+
fontSize: CATALOG_TYPE.sm,
|
|
108
|
+
fontWeight: '700',
|
|
109
|
+
color: CATALOG_COLOR.text,
|
|
110
|
+
marginTop: CATALOG_SPACE.md,
|
|
111
|
+
marginBottom: CATALOG_SPACE.xs,
|
|
112
|
+
},
|
|
98
113
|
fact: {
|
|
99
114
|
flexDirection: 'row',
|
|
100
115
|
justifyContent: 'space-between',
|
|
@@ -5,8 +5,10 @@ import { ComparisonGrid } from './ComparisonGrid';
|
|
|
5
5
|
import { ComparisonGroups } from './ComparisonGroups';
|
|
6
6
|
import { ComparisonList } from './ComparisonList';
|
|
7
7
|
import { ReferenceDetails } from './ReferenceDetails';
|
|
8
|
+
import { SpecimenSurface } from './SpecimenSurface';
|
|
9
|
+
import { TokenSections } from './TokenLayouts';
|
|
8
10
|
import { choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
|
|
9
|
-
import type { PreviewWidths, SectionDef } from './types';
|
|
11
|
+
import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
|
|
10
12
|
|
|
11
13
|
// Matches a quoted-string-literal union type, e.g. "'primary' | 'secondary' | 'tertiary'" — anything
|
|
12
14
|
// else (string, boolean, IconName, () => void, …) has no fixed enum to sweep and is skipped.
|
|
@@ -45,23 +47,27 @@ export interface SectionPager<TId extends string> {
|
|
|
45
47
|
previousId: TId | null;
|
|
46
48
|
nextId: TId | null;
|
|
47
49
|
onNavigate: (id: TId) => void;
|
|
50
|
+
/** Display title for a page id, for the buttons' accessible names. */
|
|
51
|
+
labelOf?: (id: TId) => string;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
function PagerButton<TId extends string>({
|
|
51
55
|
direction,
|
|
52
56
|
targetId,
|
|
53
57
|
onNavigate,
|
|
58
|
+
labelOf,
|
|
54
59
|
}: {
|
|
55
60
|
direction: 'previous' | 'next';
|
|
56
61
|
targetId: TId | null;
|
|
57
62
|
onNavigate: (id: TId) => void;
|
|
63
|
+
labelOf?: (id: TId) => string;
|
|
58
64
|
}) {
|
|
59
65
|
const [hovered, setHovered] = useState(false);
|
|
60
66
|
const [focused, setFocused] = useState(false);
|
|
61
67
|
const disabled = targetId == null;
|
|
62
68
|
const label = disabled
|
|
63
69
|
? direction === 'previous' ? 'No previous page' : 'No next page'
|
|
64
|
-
: `${direction === 'previous' ? 'Previous' : 'Next'}: ${targetId}`;
|
|
70
|
+
: `${direction === 'previous' ? 'Previous' : 'Next'}: ${labelOf ? labelOf(targetId) : targetId}`;
|
|
65
71
|
return (
|
|
66
72
|
<Pressable
|
|
67
73
|
onPress={() => {
|
|
@@ -95,9 +101,11 @@ function frameLabel(width: number): string {
|
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
/** A component preview: one frame per width (each at most phone width), or full width for catalog
|
|
98
|
-
* chrome and token galleries. Every frame is its own live instance with its own state.
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
* chrome and token galleries. Every frame is its own live instance with its own state. `surface`
|
|
105
|
+
* is omitted for token galleries, which keep their current, unstaged presentation. */
|
|
106
|
+
function Preview({ render, widths, surface }: { render: () => React.ReactNode; widths: PreviewWidths; surface?: SpecimenSurfaceKind }) {
|
|
107
|
+
const stage = (node: React.ReactNode) => (surface ? <SpecimenSurface surface={surface}>{node}</SpecimenSurface> : node);
|
|
108
|
+
if (widths === 'full') return <View style={styles.previewCard}>{stage(render())}</View>;
|
|
101
109
|
return (
|
|
102
110
|
<View style={styles.previewCard}>
|
|
103
111
|
<View style={styles.frames}>
|
|
@@ -109,7 +117,7 @@ function Preview({ render, widths }: { render: () => React.ReactNode; widths: Pr
|
|
|
109
117
|
style={[styles.frame, { width }]}
|
|
110
118
|
>
|
|
111
119
|
{widths.length > 1 && <Text style={styles.frameLabel}>{frameLabel(width)}</Text>}
|
|
112
|
-
{render()}
|
|
120
|
+
{stage(render())}
|
|
113
121
|
</View>
|
|
114
122
|
))}
|
|
115
123
|
</View>
|
|
@@ -117,16 +125,20 @@ function Preview({ render, widths }: { render: () => React.ReactNode; widths: Pr
|
|
|
117
125
|
);
|
|
118
126
|
}
|
|
119
127
|
|
|
120
|
-
function BlockContent<TId extends string>({ block, def }: { block: PresentationBlock; def: SectionDef<TId
|
|
128
|
+
function BlockContent<TId extends string>({ block, def, surface }: { block: PresentationBlock; def: SectionDef<TId>; surface: SpecimenSurfaceKind }) {
|
|
121
129
|
switch (block.kind) {
|
|
122
130
|
case 'grid':
|
|
123
|
-
return <ComparisonGrid def={block.comparison} size={block.size} sectionId={def.id} />;
|
|
131
|
+
return <ComparisonGrid def={block.comparison} size={block.size} sectionId={def.id} surface={surface} />;
|
|
124
132
|
case 'grouped':
|
|
125
|
-
return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} />;
|
|
133
|
+
return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} surface={surface} />;
|
|
126
134
|
case 'list':
|
|
127
|
-
return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} />;
|
|
135
|
+
return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} maxColumns={block.maxColumns} surface={surface} />;
|
|
128
136
|
case 'preview':
|
|
129
|
-
|
|
137
|
+
// A token gallery's `preview` block shows full-width raw token data, not a component
|
|
138
|
+
// specimen — it keeps its current, unstaged presentation.
|
|
139
|
+
return def.render ? <Preview render={def.render} widths={block.widths} surface={def.tokenGallery ? undefined : surface} /> : null;
|
|
140
|
+
case 'tokenSections':
|
|
141
|
+
return <TokenSections sections={block.sections} columns={block.columns} />;
|
|
130
142
|
default:
|
|
131
143
|
return (
|
|
132
144
|
<View style={styles.previewCard}>
|
|
@@ -138,7 +150,7 @@ function BlockContent<TId extends string>({ block, def }: { block: PresentationB
|
|
|
138
150
|
|
|
139
151
|
/** Width a block's card occupies: lists hug their columns; everything else fills the row. */
|
|
140
152
|
function blockWidth(block: PresentationBlock, available: number): number {
|
|
141
|
-
return block.kind === 'list' ? listGeometry(block.items.length, available, block.size).containerWidth : available;
|
|
153
|
+
return block.kind === 'list' ? listGeometry(block.items.length, available, block.size, block.maxColumns).containerWidth : available;
|
|
142
154
|
}
|
|
143
155
|
|
|
144
156
|
/**
|
|
@@ -147,7 +159,7 @@ function blockWidth(block: PresentationBlock, available: number): number {
|
|
|
147
159
|
* are never recorded while side by side, so the decision never feeds on its own result; a width
|
|
148
160
|
* change returns to stacked and decides again.
|
|
149
161
|
*/
|
|
150
|
-
function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock[]; def: SectionDef<TId
|
|
162
|
+
function Blocks<TId extends string>({ blocks, def, surface }: { blocks: PresentationBlock[]; def: SectionDef<TId>; surface: SpecimenSurfaceKind }) {
|
|
151
163
|
const [available, setAvailable] = useState(0);
|
|
152
164
|
const [heights, setHeights] = useState<number[]>([]);
|
|
153
165
|
const [placement, setPlacement] = useState<'side' | 'stacked'>('stacked');
|
|
@@ -180,7 +192,7 @@ function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock
|
|
|
180
192
|
gap: CATALOG_LAYOUT.blockGap,
|
|
181
193
|
first: { kind: first.kind, width: blockWidth(first, available), height: heights[0] },
|
|
182
194
|
second: second.kind === 'list'
|
|
183
|
-
? { kind: 'list', height: heights[1], itemCount: second.items.length, size: second.size }
|
|
195
|
+
? { kind: 'list', height: heights[1], itemCount: second.items.length, size: second.size, maxColumns: second.maxColumns }
|
|
184
196
|
: { kind: second.kind, height: heights[1] },
|
|
185
197
|
});
|
|
186
198
|
if (decision === 'side') setPlacement('side');
|
|
@@ -191,12 +203,12 @@ function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock
|
|
|
191
203
|
<View onLayout={onContainerLayout} style={[styles.blocks, side && styles.blocksSide]}>
|
|
192
204
|
{blocks.map((block, i) => (
|
|
193
205
|
<View
|
|
194
|
-
key={block.title}
|
|
206
|
+
key={block.title || block.kind}
|
|
195
207
|
onLayout={onBlockLayout(i)}
|
|
196
208
|
style={side ? (i === 0 ? { width: blockWidth(block, available), flexShrink: 0 } : styles.blockFill) : undefined}
|
|
197
209
|
>
|
|
198
|
-
<Text style={styles.blockLabel}>{block.title}</Text>
|
|
199
|
-
<BlockContent block={block} def={def} />
|
|
210
|
+
{block.title ? <Text style={styles.blockLabel}>{block.title}</Text> : null}
|
|
211
|
+
<BlockContent block={block} def={def} surface={surface} />
|
|
200
212
|
</View>
|
|
201
213
|
))}
|
|
202
214
|
</View>
|
|
@@ -234,6 +246,7 @@ export function SectionBlock<TId extends string>({
|
|
|
234
246
|
checkCompleteness(def);
|
|
235
247
|
// Memoized so Blocks' placement effect runs on real changes, not on every render.
|
|
236
248
|
const blocks = useMemo(() => presentationBlocks(def, { defaultPreviewWidths }), [def, defaultPreviewWidths]);
|
|
249
|
+
const surface = def.specimenSurface ?? 'neutral';
|
|
237
250
|
// react-native-web reads `aria-level`; React Native's prop types do not declare it.
|
|
238
251
|
const headingLevelProps = { 'aria-level': headingLevel } as Record<string, unknown>;
|
|
239
252
|
|
|
@@ -247,19 +260,24 @@ export function SectionBlock<TId extends string>({
|
|
|
247
260
|
<View style={styles.titleText}>
|
|
248
261
|
{/* The focus target is the heading itself, so assistive tech announces its role and level. */}
|
|
249
262
|
<View ref={headingRef} tabIndex={-1} role="heading" {...headingLevelProps} style={styles.headingTarget}>
|
|
250
|
-
<Text style={styles.title}>{def.id}</Text>
|
|
263
|
+
<Text style={styles.title}>{def.title ?? def.id}</Text>
|
|
251
264
|
</View>
|
|
265
|
+
{def.osComponent && (
|
|
266
|
+
<View style={styles.osBadge}>
|
|
267
|
+
<Text style={styles.osBadgeText}>{def.osComponent === 'full' ? 'OS component' : 'Partly OS component'}</Text>
|
|
268
|
+
</View>
|
|
269
|
+
)}
|
|
252
270
|
<Text style={styles.desc}>{def.description}</Text>
|
|
253
271
|
</View>
|
|
254
272
|
{pager && (
|
|
255
273
|
<View style={styles.pager}>
|
|
256
|
-
<PagerButton direction="previous" targetId={pager.previousId} onNavigate={pager.onNavigate} />
|
|
257
|
-
<PagerButton direction="next" targetId={pager.nextId} onNavigate={pager.onNavigate} />
|
|
274
|
+
<PagerButton direction="previous" targetId={pager.previousId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
|
|
275
|
+
<PagerButton direction="next" targetId={pager.nextId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
|
|
258
276
|
</View>
|
|
259
277
|
)}
|
|
260
278
|
</View>
|
|
261
279
|
|
|
262
|
-
{blocks.length > 0 && <Blocks blocks={blocks} def={def} />}
|
|
280
|
+
{blocks.length > 0 && <Blocks blocks={blocks} def={def} surface={surface} />}
|
|
263
281
|
|
|
264
282
|
<ReferenceDetails def={def} />
|
|
265
283
|
</View>
|
|
@@ -278,6 +296,17 @@ const styles = StyleSheet.create({
|
|
|
278
296
|
},
|
|
279
297
|
titleText: { flex: 1 },
|
|
280
298
|
headingTarget: { alignSelf: 'flex-start' },
|
|
299
|
+
osBadge: {
|
|
300
|
+
alignSelf: 'flex-start',
|
|
301
|
+
marginBottom: CATALOG_SPACE.sm,
|
|
302
|
+
paddingHorizontal: CATALOG_SPACE.sm,
|
|
303
|
+
paddingVertical: 3,
|
|
304
|
+
borderRadius: CATALOG_RADIUS.control,
|
|
305
|
+
borderWidth: 1,
|
|
306
|
+
borderColor: CATALOG_COLOR.borderStrong,
|
|
307
|
+
backgroundColor: CATALOG_COLOR.surface,
|
|
308
|
+
},
|
|
309
|
+
osBadgeText: { fontSize: CATALOG_TYPE.xs, fontWeight: '700', letterSpacing: 0.4, color: CATALOG_COLOR.text },
|
|
281
310
|
title: { fontSize: CATALOG_TYPE.pageTitle, fontWeight: '700', color: CATALOG_COLOR.text, marginBottom: 5 },
|
|
282
311
|
desc: { fontSize: CATALOG_TYPE.md, lineHeight: 20, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
|
|
283
312
|
pager: { flexDirection: 'row', gap: 6, flexShrink: 0 },
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_SPACE } from './tokens';
|
|
4
|
+
import { SPECIMEN_SURFACE_WIDTH_STYLE } from './specimenSurfaceStyle';
|
|
5
|
+
import type { SpecimenSurfaceKind } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The catalog-owned background directly behind a live component specimen — shared by
|
|
9
|
+
* `ComparisonList`, `ComparisonGroups`, `ComparisonGrid`, and `SectionBlock`'s component preview,
|
|
10
|
+
* so a page's `specimenSurface` renders identically everywhere a specimen appears. Purely a fixed
|
|
11
|
+
* backdrop: it never reads the wrapped component's own colors, never adds a border, and is never
|
|
12
|
+
* used for token galleries/sections (those keep their existing full-width presentation). Always
|
|
13
|
+
* stretches to its parent's width (see `specimenSurfaceStyle.ts`) rather than shrink-wrapping to
|
|
14
|
+
* its content, centering that content itself — so a percentage-width specimen resolves against
|
|
15
|
+
* the real comparison-cell width instead of collapsing to 0px.
|
|
16
|
+
*/
|
|
17
|
+
export function SpecimenSurface({
|
|
18
|
+
surface = 'neutral',
|
|
19
|
+
style,
|
|
20
|
+
children,
|
|
21
|
+
}: {
|
|
22
|
+
surface?: SpecimenSurfaceKind;
|
|
23
|
+
style?: StyleProp<ViewStyle>;
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
}) {
|
|
26
|
+
return <View style={[styles.base, SURFACE_FILL[surface], style]}>{children}</View>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token) rather than adding a new
|
|
30
|
+
// background-specific color — the catalog chrome has no existing dark-surface token to reach for.
|
|
31
|
+
const SURFACE_FILL: Record<SpecimenSurfaceKind, ViewStyle> = {
|
|
32
|
+
neutral: { backgroundColor: CATALOG_COLOR.surfaceMuted },
|
|
33
|
+
white: { backgroundColor: CATALOG_COLOR.surface },
|
|
34
|
+
dark: { backgroundColor: CATALOG_COLOR.text },
|
|
35
|
+
transparent: { backgroundColor: 'transparent' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const styles = StyleSheet.create({
|
|
39
|
+
base: {
|
|
40
|
+
...SPECIMEN_SURFACE_WIDTH_STYLE,
|
|
41
|
+
alignItems: 'center',
|
|
42
|
+
justifyContent: 'center',
|
|
43
|
+
borderRadius: CATALOG_RADIUS.sm,
|
|
44
|
+
padding: CATALOG_SPACE.md,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
|
|
4
|
+
import { sectionColumns, tileGeometry, TOKEN_LAYOUT_GAP } from './tokenLayout';
|
|
5
|
+
import type { TokenSection } from './types';
|
|
6
|
+
|
|
7
|
+
/** Measures its own width; renders nothing inside until the width is known. */
|
|
8
|
+
function useWidth(): [number, (event: LayoutChangeEvent) => void] {
|
|
9
|
+
const [width, setWidth] = useState(0);
|
|
10
|
+
return [width, (event) => {
|
|
11
|
+
const next = Math.round(event.nativeEvent.layout.width);
|
|
12
|
+
if (next > 0 && next !== width) setWidth(next);
|
|
13
|
+
}];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A token page's sections (`SectionDef.tokenSections`): one titled card per kind of token, flowing
|
|
18
|
+
* in up to `columns` columns. A `wide` section takes a full row; the page drops to fewer columns
|
|
19
|
+
* rather than squeeze a section under its minimum width.
|
|
20
|
+
*/
|
|
21
|
+
export function TokenSections({ sections, columns }: { sections: TokenSection[]; columns: 1 | 2 | 3 }) {
|
|
22
|
+
const [width, onLayout] = useWidth();
|
|
23
|
+
const perRow = sectionColumns(width, columns);
|
|
24
|
+
const columnWidth = perRow === 1 ? width : (width - TOKEN_LAYOUT_GAP * (perRow - 1)) / perRow;
|
|
25
|
+
return (
|
|
26
|
+
<View onLayout={onLayout} style={styles.sections}>
|
|
27
|
+
{width > 0 && sections.map((section) => (
|
|
28
|
+
<View key={section.title} style={{ width: section.wide || perRow === 1 ? width : columnWidth }}>
|
|
29
|
+
<Text style={styles.sectionLabel}>{section.title}</Text>
|
|
30
|
+
{section.desc ? <Text style={styles.sectionDesc}>{section.desc}</Text> : null}
|
|
31
|
+
<View style={styles.card}>{section.render()}</View>
|
|
32
|
+
</View>
|
|
33
|
+
))}
|
|
34
|
+
</View>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Tokens as tiles in columns — for tokens whose preview is small and self-contained (radius,
|
|
40
|
+
* shadow, colour, control height). As many equal tiles per row as keep `minTileWidth`.
|
|
41
|
+
*/
|
|
42
|
+
export function TokenGrid({ children, minTileWidth = 140, maxColumns }: { children: React.ReactNode; minTileWidth?: number; maxColumns?: number }) {
|
|
43
|
+
const [width, onLayout] = useWidth();
|
|
44
|
+
const { tileWidth } = tileGeometry(width, minTileWidth, maxColumns);
|
|
45
|
+
return (
|
|
46
|
+
<View onLayout={onLayout} style={styles.grid}>
|
|
47
|
+
{width > 0 && React.Children.map(children, (child) => (child ? <View style={{ width: tileWidth }}>{child}</View> : null))}
|
|
48
|
+
</View>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One token in a `TokenGrid`: its preview, then its name and value, then its role. `note` says
|
|
54
|
+
* where or why the token is used (from its comments or real usages) — omit it when there is none.
|
|
55
|
+
*/
|
|
56
|
+
export function TokenTile({ name, value, note, children }: { name: string; value?: string; note?: string; children: React.ReactNode }) {
|
|
57
|
+
return (
|
|
58
|
+
<View style={styles.tile}>
|
|
59
|
+
<View style={styles.tilePreview}>{children}</View>
|
|
60
|
+
<Text style={styles.tileName}>{name}</Text>
|
|
61
|
+
{value ? <Text style={styles.tileValue}>{value}</Text> : null}
|
|
62
|
+
{note ? <Text style={styles.tileNote}>{note}</Text> : null}
|
|
63
|
+
</View>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const styles = StyleSheet.create({
|
|
68
|
+
sections: { flexDirection: 'row', flexWrap: 'wrap', gap: TOKEN_LAYOUT_GAP, rowGap: CATALOG_LAYOUT.blockGap, alignItems: 'flex-start' },
|
|
69
|
+
sectionLabel: {
|
|
70
|
+
fontSize: CATALOG_TYPE.sm,
|
|
71
|
+
fontWeight: '700',
|
|
72
|
+
letterSpacing: 0.72,
|
|
73
|
+
textTransform: 'uppercase',
|
|
74
|
+
color: CATALOG_COLOR.text,
|
|
75
|
+
},
|
|
76
|
+
sectionDesc: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: CATALOG_SPACE.xs },
|
|
77
|
+
card: {
|
|
78
|
+
marginTop: CATALOG_LAYOUT.blockLabelGap,
|
|
79
|
+
backgroundColor: CATALOG_COLOR.surface,
|
|
80
|
+
borderWidth: 1,
|
|
81
|
+
borderColor: CATALOG_COLOR.borderStrong,
|
|
82
|
+
borderRadius: CATALOG_RADIUS.card,
|
|
83
|
+
padding: CATALOG_SPACE.xl,
|
|
84
|
+
gap: CATALOG_SPACE.md,
|
|
85
|
+
},
|
|
86
|
+
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: TOKEN_LAYOUT_GAP },
|
|
87
|
+
tile: { gap: CATALOG_SPACE.xs },
|
|
88
|
+
tilePreview: { minHeight: 56, justifyContent: 'center', marginBottom: CATALOG_SPACE.xs },
|
|
89
|
+
tileName: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', color: CATALOG_COLOR.text },
|
|
90
|
+
tileValue: { fontSize: CATALOG_TYPE.xs, color: CATALOG_COLOR.textMuted },
|
|
91
|
+
tileNote: { fontSize: CATALOG_TYPE.xs, color: CATALOG_COLOR.textMuted, lineHeight: 16 },
|
|
92
|
+
});
|
|
@@ -11,21 +11,46 @@ import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE } from './tokens';
|
|
|
11
11
|
*
|
|
12
12
|
* Draws a 1px `CATALOG_COLOR.border` bottom divider by default (the same divider PropsTable's rows use) so a stack of TokenRows
|
|
13
13
|
* reads as a list, not a loose pile of paragraphs. Pass `last` on the final row in a stack to drop
|
|
14
|
-
* the divider, matching PropsTable's own `rowLast` convention.
|
|
14
|
+
* the divider, matching PropsTable's own `rowLast` convention. Balanced `CATALOG_SPACE.md` padding
|
|
15
|
+
* sits above and below both the content and its use-note, so a stack of rows breathes evenly
|
|
16
|
+
* instead of only the divider-adjacent gap existing.
|
|
15
17
|
*/
|
|
16
|
-
export function TokenRow({
|
|
18
|
+
export function TokenRow({
|
|
19
|
+
children,
|
|
20
|
+
use,
|
|
21
|
+
last = false,
|
|
22
|
+
notePlacement = 'below',
|
|
23
|
+
sampleWidth = 280,
|
|
24
|
+
}: {
|
|
25
|
+
children: React.ReactNode;
|
|
26
|
+
use: string;
|
|
27
|
+
last?: boolean;
|
|
28
|
+
/** `'right'` puts the note beside a narrow sample (icon sizes, spacing, control heights) so each
|
|
29
|
+
* token is one line; the sample keeps `sampleWidth` and the note takes the rest. */
|
|
30
|
+
notePlacement?: 'below' | 'right';
|
|
31
|
+
sampleWidth?: number;
|
|
32
|
+
}) {
|
|
33
|
+
if (notePlacement === 'right') {
|
|
34
|
+
return (
|
|
35
|
+
<View style={[styles.itemRight, !last && styles.itemDivider]}>
|
|
36
|
+
<View style={{ width: sampleWidth, flexShrink: 0 }}>{children}</View>
|
|
37
|
+
{use ? <Text style={[styles.use, styles.useRight]}>{use}</Text> : null}
|
|
38
|
+
</View>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
17
41
|
return (
|
|
18
42
|
<View style={[styles.item, !last && styles.itemDivider]}>
|
|
19
43
|
{children}
|
|
20
|
-
<Text style={styles.use}>{use}</Text>
|
|
44
|
+
{use ? <Text style={styles.use}>{use}</Text> : null}
|
|
21
45
|
</View>
|
|
22
46
|
);
|
|
23
47
|
}
|
|
24
48
|
|
|
25
49
|
const styles = StyleSheet.create({
|
|
26
|
-
item: { gap: CATALOG_SPACE.xs },
|
|
50
|
+
item: { gap: CATALOG_SPACE.xs, paddingVertical: CATALOG_SPACE.md },
|
|
51
|
+
itemRight: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.xl, paddingVertical: CATALOG_SPACE.md },
|
|
52
|
+
useRight: { flex: 1 },
|
|
27
53
|
itemDivider: {
|
|
28
|
-
paddingBottom: CATALOG_SPACE.md,
|
|
29
54
|
borderBottomWidth: 1,
|
|
30
55
|
borderBottomColor: CATALOG_COLOR.border,
|
|
31
56
|
},
|