@krapwoo/ds-viewer 0.4.3 → 0.4.5
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 +64 -3
- package/dist/cli/explain.js +13 -8
- package/dist/cli/staticPage.js +20 -6
- package/dist/native/catalog/comparison.js +18 -6
- package/dist/native/catalog/tokens.js +2 -0
- package/native/catalog/ComparisonGrid.tsx +24 -11
- package/native/catalog/ComparisonGroups.tsx +19 -8
- package/native/catalog/ComparisonList.tsx +23 -9
- package/native/catalog/MotionSpecimen.tsx +152 -0
- package/native/catalog/ReferenceDetails.tsx +15 -0
- package/native/catalog/SectionBlock.tsx +25 -16
- package/native/catalog/SpecimenSurface.tsx +84 -0
- package/native/catalog/TokenRow.tsx +5 -4
- package/native/catalog/comparison.ts +22 -8
- package/native/catalog/index.ts +7 -1
- package/native/catalog/motionValueDomain.ts +37 -0
- package/native/catalog/pageApi.ts +21 -1
- package/native/catalog/specimenFill.ts +102 -0
- package/native/catalog/specimenSurfaceStyle.ts +21 -0
- package/native/catalog/tokens.ts +2 -0
- package/native/catalog/types.ts +34 -0
- package/package.json +6 -1
- package/starter-kit/pages/Motion.catalog.tsx +48 -20
|
@@ -5,9 +5,11 @@ 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 { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
8
10
|
import { TokenSections } from './TokenLayouts';
|
|
9
11
|
import { choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
|
|
10
|
-
import type { PreviewWidths, SectionDef } from './types';
|
|
12
|
+
import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
|
|
11
13
|
|
|
12
14
|
// Matches a quoted-string-literal union type, e.g. "'primary' | 'secondary' | 'tertiary'" — anything
|
|
13
15
|
// else (string, boolean, IconName, () => void, …) has no fixed enum to sweep and is skipped.
|
|
@@ -100,38 +102,44 @@ function frameLabel(width: number): string {
|
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
/** A component preview: one frame per width (each at most phone width), or full width for catalog
|
|
103
|
-
* chrome and token galleries. Every frame is its own live instance with its own state.
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
* 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
|
+
function Preview({ render, widths, surface }: { render: () => React.ReactNode; widths: PreviewWidths; surface?: SpecimenSurfaceKind }) {
|
|
108
|
+
// Token galleries pass no surface: their card stays white.
|
|
109
|
+
const kind = surface ?? 'transparent';
|
|
110
|
+
if (widths === 'full') return <SpecimenSurface surface={kind} style={styles.previewCard}>{render()}</SpecimenSurface>;
|
|
106
111
|
return (
|
|
107
112
|
<View style={styles.previewCard}>
|
|
108
113
|
<View style={styles.frames}>
|
|
109
114
|
{widths.map((width, i) => (
|
|
110
|
-
<
|
|
115
|
+
<SpecimenSurface
|
|
111
116
|
key={`${width}-${i}`}
|
|
117
|
+
surface={kind}
|
|
112
118
|
// Two live instances of the same component: name each so assistive tech can tell them apart.
|
|
113
119
|
{...(widths.length > 1 ? ({ role: 'group', 'aria-label': frameLabel(width) } as Record<string, unknown>) : null)}
|
|
114
120
|
style={[styles.frame, { width }]}
|
|
115
121
|
>
|
|
116
122
|
{widths.length > 1 && <Text style={styles.frameLabel}>{frameLabel(width)}</Text>}
|
|
117
123
|
{render()}
|
|
118
|
-
</
|
|
124
|
+
</SpecimenSurface>
|
|
119
125
|
))}
|
|
120
126
|
</View>
|
|
121
127
|
</View>
|
|
122
128
|
);
|
|
123
129
|
}
|
|
124
130
|
|
|
125
|
-
function BlockContent<TId extends string>({ block, def }: { block: PresentationBlock; def: SectionDef<TId
|
|
131
|
+
function BlockContent<TId extends string>({ block, def, surface }: { block: PresentationBlock; def: SectionDef<TId>; surface: SpecimenSurfaceKind }) {
|
|
126
132
|
switch (block.kind) {
|
|
127
133
|
case 'grid':
|
|
128
|
-
return <ComparisonGrid def={block.comparison} size={block.size} sectionId={def.id} />;
|
|
134
|
+
return <ComparisonGrid def={block.comparison} size={block.size} sectionId={def.id} surface={surface} />;
|
|
129
135
|
case 'grouped':
|
|
130
|
-
return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} />;
|
|
136
|
+
return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} surface={surface} />;
|
|
131
137
|
case 'list':
|
|
132
|
-
return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} />;
|
|
138
|
+
return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} maxColumns={block.maxColumns} surface={surface} />;
|
|
133
139
|
case 'preview':
|
|
134
|
-
|
|
140
|
+
// A token gallery's `preview` block shows full-width raw token data, not a component
|
|
141
|
+
// specimen — it keeps its current, unstaged presentation.
|
|
142
|
+
return def.render ? <Preview render={def.render} widths={block.widths} surface={def.tokenGallery ? undefined : surface} /> : null;
|
|
135
143
|
case 'tokenSections':
|
|
136
144
|
return <TokenSections sections={block.sections} columns={block.columns} />;
|
|
137
145
|
default:
|
|
@@ -145,7 +153,7 @@ function BlockContent<TId extends string>({ block, def }: { block: PresentationB
|
|
|
145
153
|
|
|
146
154
|
/** Width a block's card occupies: lists hug their columns; everything else fills the row. */
|
|
147
155
|
function blockWidth(block: PresentationBlock, available: number): number {
|
|
148
|
-
return block.kind === 'list' ? listGeometry(block.items.length, available, block.size).containerWidth : available;
|
|
156
|
+
return block.kind === 'list' ? listGeometry(block.items.length, available, block.size, block.maxColumns).containerWidth : available;
|
|
149
157
|
}
|
|
150
158
|
|
|
151
159
|
/**
|
|
@@ -154,7 +162,7 @@ function blockWidth(block: PresentationBlock, available: number): number {
|
|
|
154
162
|
* are never recorded while side by side, so the decision never feeds on its own result; a width
|
|
155
163
|
* change returns to stacked and decides again.
|
|
156
164
|
*/
|
|
157
|
-
function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock[]; def: SectionDef<TId
|
|
165
|
+
function Blocks<TId extends string>({ blocks, def, surface }: { blocks: PresentationBlock[]; def: SectionDef<TId>; surface: SpecimenSurfaceKind }) {
|
|
158
166
|
const [available, setAvailable] = useState(0);
|
|
159
167
|
const [heights, setHeights] = useState<number[]>([]);
|
|
160
168
|
const [placement, setPlacement] = useState<'side' | 'stacked'>('stacked');
|
|
@@ -187,7 +195,7 @@ function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock
|
|
|
187
195
|
gap: CATALOG_LAYOUT.blockGap,
|
|
188
196
|
first: { kind: first.kind, width: blockWidth(first, available), height: heights[0] },
|
|
189
197
|
second: second.kind === 'list'
|
|
190
|
-
? { kind: 'list', height: heights[1], itemCount: second.items.length, size: second.size }
|
|
198
|
+
? { kind: 'list', height: heights[1], itemCount: second.items.length, size: second.size, maxColumns: second.maxColumns }
|
|
191
199
|
: { kind: second.kind, height: heights[1] },
|
|
192
200
|
});
|
|
193
201
|
if (decision === 'side') setPlacement('side');
|
|
@@ -203,7 +211,7 @@ function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock
|
|
|
203
211
|
style={side ? (i === 0 ? { width: blockWidth(block, available), flexShrink: 0 } : styles.blockFill) : undefined}
|
|
204
212
|
>
|
|
205
213
|
{block.title ? <Text style={styles.blockLabel}>{block.title}</Text> : null}
|
|
206
|
-
<BlockContent block={block} def={def} />
|
|
214
|
+
<BlockContent block={block} def={def} surface={surface} />
|
|
207
215
|
</View>
|
|
208
216
|
))}
|
|
209
217
|
</View>
|
|
@@ -241,6 +249,7 @@ export function SectionBlock<TId extends string>({
|
|
|
241
249
|
checkCompleteness(def);
|
|
242
250
|
// Memoized so Blocks' placement effect runs on real changes, not on every render.
|
|
243
251
|
const blocks = useMemo(() => presentationBlocks(def, { defaultPreviewWidths }), [def, defaultPreviewWidths]);
|
|
252
|
+
const surface = def.specimenSurface ?? DEFAULT_SPECIMEN_SURFACE;
|
|
244
253
|
// react-native-web reads `aria-level`; React Native's prop types do not declare it.
|
|
245
254
|
const headingLevelProps = { 'aria-level': headingLevel } as Record<string, unknown>;
|
|
246
255
|
|
|
@@ -271,7 +280,7 @@ export function SectionBlock<TId extends string>({
|
|
|
271
280
|
)}
|
|
272
281
|
</View>
|
|
273
282
|
|
|
274
|
-
{blocks.length > 0 && <Blocks blocks={blocks} def={def} />}
|
|
283
|
+
{blocks.length > 0 && <Blocks blocks={blocks} def={def} surface={surface} />}
|
|
275
284
|
|
|
276
285
|
<ReferenceDetails def={def} />
|
|
277
286
|
</View>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { CATALOG_COLOR } from './tokens';
|
|
4
|
+
import { DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE } from './specimenSurfaceStyle';
|
|
5
|
+
import { blendsIntoCell, measurePaintedBoxes } from './specimenFill';
|
|
6
|
+
import type { SpecimenSurfaceKind } from './types';
|
|
7
|
+
|
|
8
|
+
type Browser = {
|
|
9
|
+
getComputedStyle?: (element: unknown) => never;
|
|
10
|
+
Element?: new () => unknown;
|
|
11
|
+
requestAnimationFrame?: (callback: () => void) => number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* An example's whole cell area — shared by `ComparisonList`, `ComparisonGroups`,
|
|
16
|
+
* `ComparisonGrid`, and `SectionBlock`'s preview frames, so a page's `specimenSurface` fills every
|
|
17
|
+
* example cell the same way. With `'auto'` (the default) the cell measures its example after it
|
|
18
|
+
* draws and turns gray only when the example would vanish against white (a white card, sheet or
|
|
19
|
+
* neutral banner; see `specimenFill.ts`). Explicit kinds fill the cell without measuring. Adds no
|
|
20
|
+
* padding or border of its own: pass the cell's own layout in `style`. Measuring needs the
|
|
21
|
+
* browser's DOM; elsewhere `'auto'` leaves the cell white. Extra props (e.g. `role`) go to the View.
|
|
22
|
+
*/
|
|
23
|
+
export function SpecimenSurface({
|
|
24
|
+
surface = DEFAULT_SPECIMEN_SURFACE,
|
|
25
|
+
style,
|
|
26
|
+
children,
|
|
27
|
+
...viewProps
|
|
28
|
+
}: {
|
|
29
|
+
surface?: SpecimenSurfaceKind;
|
|
30
|
+
style?: StyleProp<ViewStyle>;
|
|
31
|
+
children: React.ReactNode;
|
|
32
|
+
[prop: string]: unknown;
|
|
33
|
+
}) {
|
|
34
|
+
const ref = useRef<View>(null);
|
|
35
|
+
const [blends, setBlends] = useState(false);
|
|
36
|
+
|
|
37
|
+
const measure = useCallback(() => {
|
|
38
|
+
if (surface !== 'auto') return;
|
|
39
|
+
const browser = globalThis as unknown as Browser;
|
|
40
|
+
const node = ref.current as unknown;
|
|
41
|
+
if (!browser.getComputedStyle || !browser.Element || !(node instanceof browser.Element)) return;
|
|
42
|
+
const { boxes, cellArea } = measurePaintedBoxes(node as Parameters<typeof measurePaintedBoxes>[0], (element) => browser.getComputedStyle!(element));
|
|
43
|
+
setBlends(blendsIntoCell(boxes, cellArea));
|
|
44
|
+
}, [surface]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const raf = (globalThis as unknown as Browser).requestAnimationFrame;
|
|
48
|
+
if (surface !== 'auto' || !raf) return;
|
|
49
|
+
// Two frames, so the example's own styles and fonts have applied before measuring.
|
|
50
|
+
let cancelled = false;
|
|
51
|
+
raf(() => raf(() => {
|
|
52
|
+
if (!cancelled) measure();
|
|
53
|
+
}));
|
|
54
|
+
return () => {
|
|
55
|
+
cancelled = true;
|
|
56
|
+
};
|
|
57
|
+
}, [surface, measure]);
|
|
58
|
+
|
|
59
|
+
const fill = surface === 'auto' ? (blends ? FILL.neutral : undefined) : FILL[surface];
|
|
60
|
+
return (
|
|
61
|
+
<View ref={ref} onLayout={surface === 'auto' ? measure : undefined} style={[style, fill]} {...viewProps}>
|
|
62
|
+
{children}
|
|
63
|
+
</View>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Wraps one example inside its cell: full cell width (so a percentage-width example resolves
|
|
68
|
+
* against the cell), centered unless the example fills the cell. */
|
|
69
|
+
export function SpecimenContent({ fill, children }: { fill?: boolean; children: React.ReactNode }) {
|
|
70
|
+
return <View style={fill ? styles.fill : styles.center}>{children}</View>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token); there is no dark-surface token.
|
|
74
|
+
const FILL = StyleSheet.create({
|
|
75
|
+
neutral: { backgroundColor: CATALOG_COLOR.specimenStage },
|
|
76
|
+
white: { backgroundColor: CATALOG_COLOR.surface },
|
|
77
|
+
dark: { backgroundColor: CATALOG_COLOR.text },
|
|
78
|
+
transparent: {},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const styles = StyleSheet.create({
|
|
82
|
+
center: { ...SPECIMEN_SURFACE_WIDTH_STYLE, alignItems: 'center' },
|
|
83
|
+
fill: { ...SPECIMEN_SURFACE_WIDTH_STYLE },
|
|
84
|
+
});
|
|
@@ -11,7 +11,9 @@ 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
18
|
export function TokenRow({
|
|
17
19
|
children,
|
|
@@ -45,11 +47,10 @@ export function TokenRow({
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
const styles = StyleSheet.create({
|
|
48
|
-
item: { gap: CATALOG_SPACE.xs },
|
|
49
|
-
itemRight: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.xl },
|
|
50
|
+
item: { gap: CATALOG_SPACE.xs, paddingVertical: CATALOG_SPACE.md },
|
|
51
|
+
itemRight: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.xl, paddingVertical: CATALOG_SPACE.md },
|
|
50
52
|
useRight: { flex: 1 },
|
|
51
53
|
itemDivider: {
|
|
52
|
-
paddingBottom: CATALOG_SPACE.md,
|
|
53
54
|
borderBottomWidth: 1,
|
|
54
55
|
borderBottomColor: CATALOG_COLOR.border,
|
|
55
56
|
},
|
|
@@ -115,12 +115,17 @@ export interface ListGeometry {
|
|
|
115
115
|
/** Balanced wrapping for a one-axis list inside a single bordered card (1px outer border). Each
|
|
116
116
|
* cell's 1px divider sits inside its own width, so no cell is ever wider than 402px. Uses as few
|
|
117
117
|
* rows as fit, then spreads items evenly across them so a short last row leaves as little empty
|
|
118
|
-
* space as possible. `fillers` blank cells complete the last row.
|
|
119
|
-
|
|
118
|
+
* space as possible. `fillers` blank cells complete the last row. `columnCap` (from
|
|
119
|
+
* `VariantSlot.maxColumns`) only ever lowers the natural, width-based column count — a narrow
|
|
120
|
+
* container still drops below it when it has to, so the cap can never widen a row past what the
|
|
121
|
+
* available width actually fits. */
|
|
122
|
+
export function listGeometry(itemCount: number, availableWidth: number, size: SpecimenSize, columnCap?: number): ListGeometry {
|
|
120
123
|
if (itemCount <= 0) return { columns: 0, rows: 0, fillers: 0, cellWidth: 0, containerWidth: 0 };
|
|
121
124
|
const inner = Math.max(0, availableWidth - 2);
|
|
122
125
|
const min = COLUMN_MIN_WIDTH[size];
|
|
123
|
-
const
|
|
126
|
+
const widthLimit = Math.floor(inner / min);
|
|
127
|
+
const limit = columnCap !== undefined ? Math.min(widthLimit, columnCap) : widthLimit;
|
|
128
|
+
const maxColumns = Math.max(1, Math.min(itemCount, limit));
|
|
124
129
|
const rows = Math.ceil(itemCount / maxColumns);
|
|
125
130
|
const columns = Math.ceil(itemCount / rows);
|
|
126
131
|
const fitted = Math.floor(inner / columns);
|
|
@@ -228,7 +233,7 @@ export interface ListGroup {
|
|
|
228
233
|
export type PresentationBlock =
|
|
229
234
|
| { kind: 'grid'; title: string; size: SpecimenSize; comparison: ComparisonDef }
|
|
230
235
|
| { kind: 'grouped'; title: string; size: SpecimenSize; groups: ListGroup[] }
|
|
231
|
-
| { kind: 'list'; title: string; size: SpecimenSize; items: ListItem[] }
|
|
236
|
+
| { kind: 'list'; title: string; size: SpecimenSize; items: ListItem[]; maxColumns?: 1 | 2 | 3 | 4 | 5 }
|
|
232
237
|
| { kind: 'preview'; title: string; widths: PreviewWidths }
|
|
233
238
|
| { kind: 'tokenSections'; title: string; sections: TokenSection[]; columns: 1 | 2 | 3 }
|
|
234
239
|
| { kind: 'empty'; title: string; message: string };
|
|
@@ -308,7 +313,13 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
|
|
|
308
313
|
blocks.push({ kind: 'grouped', title: 'Variant × configuration', size: def.specimenSize ?? 'regular', groups: grouped.groups });
|
|
309
314
|
states = grouped.leftovers;
|
|
310
315
|
} else if (def.variants) {
|
|
311
|
-
blocks.push({
|
|
316
|
+
blocks.push({
|
|
317
|
+
kind: 'list',
|
|
318
|
+
title: 'Variants',
|
|
319
|
+
size: slotSize(def, def.variants),
|
|
320
|
+
items: slotItems(def.variants),
|
|
321
|
+
...(def.variants.maxColumns !== undefined ? { maxColumns: def.variants.maxColumns } : {}),
|
|
322
|
+
});
|
|
312
323
|
} else if (def.render) {
|
|
313
324
|
blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths) });
|
|
314
325
|
}
|
|
@@ -321,6 +332,7 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
|
|
|
321
332
|
title: secondary ? 'Other configurations' : 'States / configurations',
|
|
322
333
|
size: slotSize(def, states),
|
|
323
334
|
items: slotItems(states),
|
|
335
|
+
...(states.maxColumns !== undefined ? { maxColumns: states.maxColumns } : {}),
|
|
324
336
|
});
|
|
325
337
|
}
|
|
326
338
|
|
|
@@ -336,9 +348,11 @@ export interface PlacementBlock {
|
|
|
336
348
|
width?: number;
|
|
337
349
|
/** Measured height of the whole block, label included, while stacked. */
|
|
338
350
|
height: number;
|
|
339
|
-
/** For a list: item count
|
|
351
|
+
/** For a list: item count, specimen size, and any explicit column cap, so its side-by-side
|
|
352
|
+
* height can be predicted from the same geometry the rendered list uses. */
|
|
340
353
|
itemCount?: number;
|
|
341
354
|
size?: SpecimenSize;
|
|
355
|
+
maxColumns?: 1 | 2 | 3 | 4 | 5;
|
|
342
356
|
}
|
|
343
357
|
|
|
344
358
|
/** Side by side or stacked, for a page with exactly two blocks. Tries both and keeps the shorter;
|
|
@@ -351,8 +365,8 @@ export function choosePlacement({ available, gap, first, second }: { available:
|
|
|
351
365
|
const room = available - firstWidth - gap;
|
|
352
366
|
const minCell = second.size === 'wide' ? MATRIX_LAYOUT.columnMaxWidth : COLUMN_MIN_WIDTH[second.size];
|
|
353
367
|
if (room < minCell + 2) return 'stacked';
|
|
354
|
-
const stackedRows = listGeometry(second.itemCount, available, second.size).rows;
|
|
355
|
-
const sideRows = listGeometry(second.itemCount, room, second.size).rows;
|
|
368
|
+
const stackedRows = listGeometry(second.itemCount, available, second.size, second.maxColumns).rows;
|
|
369
|
+
const sideRows = listGeometry(second.itemCount, room, second.size, second.maxColumns).rows;
|
|
356
370
|
const rowHeight = second.height / Math.max(1, stackedRows);
|
|
357
371
|
const sideHeight = Math.max(first.height, sideRows * rowHeight);
|
|
358
372
|
const stackedHeight = first.height + gap + second.height;
|
package/native/catalog/index.ts
CHANGED
|
@@ -24,6 +24,9 @@ export { ReferenceDetails } from './ReferenceDetails';
|
|
|
24
24
|
export { PropsTable } from './PropsTable';
|
|
25
25
|
export { VariantGroup } from './VariantGroup';
|
|
26
26
|
export { TokenRow } from './TokenRow';
|
|
27
|
+
export { MotionSpecimen } from './MotionSpecimen';
|
|
28
|
+
export type { MotionSpecimenProps, MotionSpecimenSpringConfig } from './MotionSpecimen';
|
|
29
|
+
export { SpecimenSurface } from './SpecimenSurface';
|
|
27
30
|
export { TokenSections, TokenGrid, TokenTile } from './TokenLayouts';
|
|
28
31
|
export { DividedStack } from './DividedStack';
|
|
29
32
|
export { Swatch } from './Swatch';
|
|
@@ -34,7 +37,10 @@ export { buildComponentManifest } from './manifest';
|
|
|
34
37
|
export type { ComponentManifestEntry, ManifestExample } from './manifest';
|
|
35
38
|
export { defineCatalogPage, buildCatalogSections } from './pageApi';
|
|
36
39
|
export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRecord } from './pageApi';
|
|
37
|
-
export type {
|
|
40
|
+
export type {
|
|
41
|
+
PropDef, SectionDef, NavGroup, SpecimenSize, SpecimenSurfaceKind, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis,
|
|
42
|
+
PreviewWidths, TokenSection, ComposedOfEntry,
|
|
43
|
+
} from './types';
|
|
38
44
|
export { UpdatePanel } from './UpdatePanel';
|
|
39
45
|
export { UPDATE_PAGE_ID } from './catalogNavigation';
|
|
40
46
|
export type { UpdateNotice } from './types';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure value-domain model for `MotionSpecimen`'s spring kind, kept free of any `react-native`
|
|
3
|
+
* import so `node --test` can assert on it directly (see
|
|
4
|
+
* `native/catalog/__tests__/motionValueDomain.test.ts`).
|
|
5
|
+
*
|
|
6
|
+
* A spring's `restDisplacementThreshold`/`restSpeedThreshold` are in the units of the value being
|
|
7
|
+
* animated — a config built for pixel-space points (e.g. a bottom sheet's snap-point spring) stops
|
|
8
|
+
* within a fraction of a pixel of its target, which is invisible; the same thresholds applied to a
|
|
9
|
+
* normalized 0-to-1 progress would end the animation before it ever emits a frame. `MotionSpecimen` must animate the domain the config was
|
|
10
|
+
* actually tuned for, rather than always normalizing to 0–1, or it misrepresents any spring whose
|
|
11
|
+
* production value is pixel-space.
|
|
12
|
+
*
|
|
13
|
+
* 'distance': the `Animated.Value` IS the pixel distance (0 → `distance`); `translateX` reads it
|
|
14
|
+
* directly, with no interpolation. 'unit': the value is a normalized 0-to-1 progress; `translateX`
|
|
15
|
+
* interpolates it to the pixel distance. Timing specimens always use 'distance' because their
|
|
16
|
+
* easing and duration are independent of the animated value's units.
|
|
17
|
+
*/
|
|
18
|
+
export type MotionValueRange = 'distance' | 'unit';
|
|
19
|
+
|
|
20
|
+
/** The Replay button's accessible name. A motion page shows many specimens, so each name must say
|
|
21
|
+
* which one it replays: the specimen's visible label, else its duration (timing) or "spring". */
|
|
22
|
+
export function motionReplayLabel(specimen: { kind: 'timing' | 'spring'; duration?: number; label?: string }): string {
|
|
23
|
+
if (specimen.label) return `Replay ${specimen.label}`;
|
|
24
|
+
return specimen.kind === 'timing' ? `Replay ${specimen.duration}ms timing` : 'Replay spring';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The `Animated.Value` target to animate toward — and the value a reduced-motion replay jumps to
|
|
28
|
+
* instantly — for a given domain and pixel distance. */
|
|
29
|
+
export function motionValueTarget(valueRange: MotionValueRange, distance: number): number {
|
|
30
|
+
return valueRange === 'distance' ? distance : 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether `translateX` reads the animated value directly (true, 'distance') or must interpolate
|
|
34
|
+
* it from a 0-to-1 progress to the pixel distance (false, 'unit'). */
|
|
35
|
+
export function motionTranslatesDirectly(valueRange: MotionValueRange): boolean {
|
|
36
|
+
return valueRange === 'distance';
|
|
37
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
|
-
import type { ComparisonDef, NavGroup, PreviewWidths, PropDef, SectionDef, SpecimenSize, TokenSection, VariantSlot } from './types';
|
|
2
|
+
import type { ComparisonDef, ComposedOfEntry, NavGroup, PreviewWidths, PropDef, SectionDef, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot } from './types';
|
|
3
3
|
|
|
4
4
|
/** `SectionDef` without the derived fields (`id`/`path`/`props` come from detection and
|
|
5
5
|
* generated props), plus the three fields design §1 "Page shape" adds. */
|
|
@@ -29,8 +29,10 @@ export interface CatalogPageInput<TId extends string = string> {
|
|
|
29
29
|
fullWidthLabel?: string;
|
|
30
30
|
comparison?: ComparisonDef;
|
|
31
31
|
specimenSize?: SpecimenSize;
|
|
32
|
+
specimenSurface?: SpecimenSurfaceKind;
|
|
32
33
|
previewWidths?: PreviewWidths;
|
|
33
34
|
hide?: SectionDef['hide'];
|
|
35
|
+
composedOf?: ComposedOfEntry[];
|
|
34
36
|
/** Export name this page documents; defaults to `id` for component pages. */
|
|
35
37
|
component?: string;
|
|
36
38
|
/** Sidebar group label. */
|
|
@@ -87,6 +89,17 @@ function mergePropNotes(component: GeneratedComponent, propNotes: Record<string,
|
|
|
87
89
|
return [...own, ...inherited];
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
/** Human-readable problems with a page's `composedOf`, in declaration order: one line per entry
|
|
93
|
+
* whose `component` isn't in `knownNames` (every real component name, documented or not, plus
|
|
94
|
+
* every page id — the full catalog inventory `buildCatalogSections` already has on hand). Empty
|
|
95
|
+
* when every name resolves, including when `composedOf` itself is omitted. */
|
|
96
|
+
export function validateComposedOf(composedOf: ComposedOfEntry[] | undefined, knownNames: ReadonlySet<string>): string[] {
|
|
97
|
+
if (!composedOf) return [];
|
|
98
|
+
return composedOf
|
|
99
|
+
.filter((entry) => !knownNames.has(entry.component))
|
|
100
|
+
.map((entry) => `composedOf references unknown component "${entry.component}".`);
|
|
101
|
+
}
|
|
102
|
+
|
|
90
103
|
function orderGroups<TId extends string>(groupIds: Map<string, TId[]>, groupOrder: string[] | undefined): NavGroup<TId>[] {
|
|
91
104
|
const listed = groupOrder ?? [];
|
|
92
105
|
const labels = [...groupIds.keys()];
|
|
@@ -112,6 +125,10 @@ export function buildCatalogSections<TId extends string>(
|
|
|
112
125
|
const documented = new Set(pages.map((page) => page.component ?? page.id));
|
|
113
126
|
const groupIds = new Map<string, TId[]>();
|
|
114
127
|
const addToGroup = (group: string, id: TId) => groupIds.set(group, [...(groupIds.get(group) ?? []), id]);
|
|
128
|
+
// Every name a page's `composedOf` could legitimately point at: every real component (documented
|
|
129
|
+
// or not — an undocumented one is still a real part of the design system) plus every page id
|
|
130
|
+
// (token pages, standalone recipes, …).
|
|
131
|
+
const knownComposedOfNames = new Set<string>([...componentByName.keys(), ...pages.map((page) => page.id)]);
|
|
115
132
|
|
|
116
133
|
// Design §4 "duplicate-page-id" rule's viewer-side equivalent — this is the one place in the
|
|
117
134
|
// whole viewer that already resolves every page's id to build sections/groups, so it costs
|
|
@@ -122,6 +139,7 @@ export function buildCatalogSections<TId extends string>(
|
|
|
122
139
|
if ((globalThis as { __DEV__?: boolean }).__DEV__) {
|
|
123
140
|
if (seenIds.has(page.id)) console.warn(`[Catalog] Page id "${page.id}" is used by more than one page; only the last one will show. Give one of them its own "id".`);
|
|
124
141
|
seenIds.add(page.id);
|
|
142
|
+
for (const issue of validateComposedOf(page.composedOf, knownComposedOfNames)) console.warn(`[Catalog] ${page.id}: ${issue}`);
|
|
125
143
|
}
|
|
126
144
|
const componentName = page.component ?? page.id;
|
|
127
145
|
const component = componentByName.get(componentName);
|
|
@@ -148,8 +166,10 @@ export function buildCatalogSections<TId extends string>(
|
|
|
148
166
|
fullWidthLabel: page.fullWidthLabel,
|
|
149
167
|
comparison: page.comparison,
|
|
150
168
|
specimenSize: page.specimenSize,
|
|
169
|
+
specimenSurface: page.specimenSurface,
|
|
151
170
|
previewWidths: page.previewWidths,
|
|
152
171
|
hide: component ? page.hide : { ...page.hide, props: true },
|
|
172
|
+
composedOf: page.composedOf,
|
|
153
173
|
};
|
|
154
174
|
});
|
|
155
175
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic cell fill for `specimenSurface: 'auto'` (the default): decides whether an example
|
|
3
|
+
* blends into its white comparison cell, so that one cell can turn gray. Kept free of any runtime
|
|
4
|
+
* import so `node --test` can assert on it directly (see `__tests__/specimenFill.test.ts`).
|
|
5
|
+
*
|
|
6
|
+
* The rule looks only at the example's largest painted box (the element with a background that
|
|
7
|
+
* covers the most area): if that box is near-white (every channel close to 255, so pale tints
|
|
8
|
+
* don't count) and has no visible border, the example's edges
|
|
9
|
+
* vanish against the white cell. A shadow alone doesn't count as an edge. Small boxes (under a
|
|
10
|
+
* tenth of the cell) and text-only examples never trigger it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface Rgba { r: number; g: number; b: number; a: number }
|
|
14
|
+
|
|
15
|
+
/** One element inside an example, as measured in the browser. */
|
|
16
|
+
export interface PaintedBox {
|
|
17
|
+
area: number;
|
|
18
|
+
background: Rgba | null;
|
|
19
|
+
/** The top border's colour when its width is above 0, else null. */
|
|
20
|
+
border: Rgba | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A fill is near-white when every channel (after compositing over the white cell) is at least
|
|
24
|
+
* this. #fafafa and #f8f8f8 qualify; #f4f4f4 already reads as gray, and pale tints such as a
|
|
25
|
+
* positive banner's rgb(240, 255, 244) read as colour, even when they're as bright as white. */
|
|
26
|
+
const NEAR_WHITE_CHANNEL = 247;
|
|
27
|
+
/** At or above this, a border draws the edge itself (#e4e4e4 is 1.27:1). */
|
|
28
|
+
const VISIBLE_BORDER_CONTRAST = 1.2;
|
|
29
|
+
/** A box smaller than this share of the cell is a detail, not the example's body. */
|
|
30
|
+
const MIN_AREA_SHARE = 0.1;
|
|
31
|
+
|
|
32
|
+
/** Parses a computed CSS colour (`rgb(…)`/`rgba(…)`). Fully transparent or unknown values are null. */
|
|
33
|
+
export function parseCssColor(value: string): Rgba | null {
|
|
34
|
+
const match = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/.exec(value.trim());
|
|
35
|
+
if (!match) return null;
|
|
36
|
+
const alphaText = match[4];
|
|
37
|
+
const a = alphaText === undefined ? 1 : alphaText.endsWith('%') ? Number(alphaText.slice(0, -1)) / 100 : Number(alphaText);
|
|
38
|
+
if (!(a > 0)) return null;
|
|
39
|
+
return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]), a };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function channel(value: number): number {
|
|
43
|
+
const s = value / 255;
|
|
44
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when every channel of `color`, composited over white, is at least `NEAR_WHITE_CHANNEL`. */
|
|
48
|
+
export function isNearWhite(color: Rgba): boolean {
|
|
49
|
+
const over = (c: number) => color.a * c + (1 - color.a) * 255;
|
|
50
|
+
return Math.min(over(color.r), over(color.g), over(color.b)) >= NEAR_WHITE_CHANNEL;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** WCAG contrast ratio against white, after compositing a translucent colour over the white cell. */
|
|
54
|
+
export function contrastWithWhite(color: Rgba): number {
|
|
55
|
+
const over = (c: number) => color.a * c + (1 - color.a) * 255;
|
|
56
|
+
const luminance = 0.2126 * channel(over(color.r)) + 0.7152 * channel(over(color.g)) + 0.0722 * channel(over(color.b));
|
|
57
|
+
return 1.05 / (luminance + 0.05);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when the example's body is near-white with no visible border, so it vanishes on a white cell. */
|
|
61
|
+
export function blendsIntoCell(boxes: readonly PaintedBox[], cellArea: number): boolean {
|
|
62
|
+
let largest: PaintedBox | undefined;
|
|
63
|
+
for (const box of boxes) {
|
|
64
|
+
if (!box.background || box.area < cellArea * MIN_AREA_SHARE) continue;
|
|
65
|
+
if (!largest || box.area > largest.area) largest = box;
|
|
66
|
+
}
|
|
67
|
+
if (!largest?.background) return false;
|
|
68
|
+
if (!isNearWhite(largest.background)) return false;
|
|
69
|
+
return !(largest.border && contrastWithWhite(largest.border) >= VISIBLE_BORDER_CONTRAST);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Minimal DOM shapes, so this file needs no DOM lib types. */
|
|
73
|
+
interface MeasurableElement {
|
|
74
|
+
getBoundingClientRect(): { width: number; height: number };
|
|
75
|
+
querySelectorAll(selector: string): ArrayLike<MeasurableElement>;
|
|
76
|
+
}
|
|
77
|
+
type ComputedStyleReader = (element: MeasurableElement) => {
|
|
78
|
+
backgroundColor: string;
|
|
79
|
+
borderTopColor: string;
|
|
80
|
+
borderTopWidth: string;
|
|
81
|
+
visibility: string;
|
|
82
|
+
opacity: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Measures every element inside `cell` (up to `limit`) in the browser. */
|
|
86
|
+
export function measurePaintedBoxes(cell: MeasurableElement, readStyle: ComputedStyleReader, limit = 300): { boxes: PaintedBox[]; cellArea: number } {
|
|
87
|
+
const cellRect = cell.getBoundingClientRect();
|
|
88
|
+
const elements = cell.querySelectorAll('*');
|
|
89
|
+
const boxes: PaintedBox[] = [];
|
|
90
|
+
for (let i = 0; i < Math.min(elements.length, limit); i++) {
|
|
91
|
+
const element = elements[i];
|
|
92
|
+
const style = readStyle(element);
|
|
93
|
+
if (style.visibility === 'hidden' || Number(style.opacity) === 0) continue;
|
|
94
|
+
const rect = element.getBoundingClientRect();
|
|
95
|
+
boxes.push({
|
|
96
|
+
area: rect.width * rect.height,
|
|
97
|
+
background: parseCssColor(style.backgroundColor),
|
|
98
|
+
border: parseFloat(style.borderTopWidth) > 0 ? parseCssColor(style.borderTopColor) : null,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return { boxes, cellArea: cellRect.width * cellRect.height };
|
|
102
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SpecimenSurfaceKind } from './types.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The example wrapper's width policy inside a cell, kept free of any `react-native` import so
|
|
5
|
+
* `node --test` can assert on it directly (see `__tests__/specimenSurfaceStyle.test.ts`).
|
|
6
|
+
*
|
|
7
|
+
* The wrapper always stretches to the full cross-axis width of whatever wraps it (a comparison
|
|
8
|
+
* cell, typically centered) rather than shrink-wrapping to its own content — `alignSelf: 'stretch'`
|
|
9
|
+
* overrides a centered parent's `alignItems` for this one child. A compact specimen (e.g. Button)
|
|
10
|
+
* still reads as centered, via `SpecimenSurface`'s own `alignItems`/`justifyContent: 'center'` on
|
|
11
|
+
* the now-full-width surface. Before this, a percentage-width child (Divider's `width: '100%'`, a
|
|
12
|
+
* phone mockup's percentage-width content) resolved against the surface's own shrink-wrapped,
|
|
13
|
+
* intrinsic width inside a centered comparison cell, and rendered at 0px.
|
|
14
|
+
*/
|
|
15
|
+
export const SPECIMEN_SURFACE_WIDTH_STYLE = {
|
|
16
|
+
alignSelf: 'stretch',
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
/** Each example's cell turns gray only when its example would vanish against white (see
|
|
20
|
+
* `specimenFill.ts`). A page can force a fill with `specimenSurface`. */
|
|
21
|
+
export const DEFAULT_SPECIMEN_SURFACE: SpecimenSurfaceKind = 'auto';
|
package/native/catalog/tokens.ts
CHANGED
|
@@ -119,6 +119,8 @@ export const CATALOG_COLOR = {
|
|
|
119
119
|
surfaceMuted: '#fafafa',
|
|
120
120
|
/** Hover and pressed feedback on nav rows and buttons. */
|
|
121
121
|
surfacePressed: '#f1f3f8',
|
|
122
|
+
/** An example's cell when the example itself is white (`specimenSurface`), darker than surfaceMuted so a white card's edges show. */
|
|
123
|
+
specimenStage: '#f2f2f2',
|
|
122
124
|
pageBackground: '#f6f6f4',
|
|
123
125
|
chip: '#eeeeee',
|
|
124
126
|
/** Active nav label, prop types, scale bars. */
|
package/native/catalog/types.ts
CHANGED
|
@@ -48,6 +48,12 @@ export interface VariantSlot {
|
|
|
48
48
|
* content width — for wide block-level components (Banner, Card, Toast, InputField) rather than
|
|
49
49
|
* small instances meant to sit centered (Button, Badge, Pill). @default false */
|
|
50
50
|
itemsFill?: boolean;
|
|
51
|
+
/** Caps how many columns this slot's `ComparisonList` ever wraps into — e.g. a size scale that
|
|
52
|
+
* must read as one row of exactly 3, never more, regardless of how much laptop width is free.
|
|
53
|
+
* `listGeometry` still drops to fewer columns than this when the available width requires it;
|
|
54
|
+
* it only ever lowers the natural, width-based column count, never raises it. Omit to let the
|
|
55
|
+
* slot's specimen size and available width decide alone (today's behavior, unchanged). */
|
|
56
|
+
maxColumns?: 1 | 2 | 3 | 4 | 5;
|
|
51
57
|
items: VariantExample[];
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -59,6 +65,26 @@ export type SpecimenSize = 'compact' | 'regular' | 'wide';
|
|
|
59
65
|
* content that is not a phone component (catalog chrome, token galleries). */
|
|
60
66
|
export type PreviewWidths = readonly number[] | 'full';
|
|
61
67
|
|
|
68
|
+
/** Fill of each example's cell. `'auto'` (default): a cell turns gray (#f2f2f2) only when its own
|
|
69
|
+
* example would vanish against white — a near-white body with no visible border, like a white
|
|
70
|
+
* card, sheet or neutral banner — so neighbouring examples keep a white cell. `'neutral'` makes
|
|
71
|
+
* every example cell gray, `'white'` and `'transparent'` keep them white, `'dark'` checks light
|
|
72
|
+
* components on a dark fill. Never applied to token galleries or sections. */
|
|
73
|
+
export type SpecimenSurfaceKind = 'auto' | 'neutral' | 'white' | 'dark' | 'transparent';
|
|
74
|
+
|
|
75
|
+
/** One real component this page's component is built from or composed with — rendered as a
|
|
76
|
+
* "Composition" subsection in Quick reference. `relationship` is `'built-in'` for a component
|
|
77
|
+
* always present (e.g. Toast is built from a Banner internally), `'slot'` for an optional
|
|
78
|
+
* caller-supplied child (e.g. a `leadingIcon` prop), or `'related'` for a component commonly used
|
|
79
|
+
* alongside this one without either containing the other. In development, `buildCatalogSections`
|
|
80
|
+
* warns in the browser console when `component` isn't a known component or page id. `doctor`
|
|
81
|
+
* doesn't check it, and it never fails a build. */
|
|
82
|
+
export interface ComposedOfEntry {
|
|
83
|
+
component: string;
|
|
84
|
+
role: string;
|
|
85
|
+
relationship: 'built-in' | 'slot' | 'related';
|
|
86
|
+
}
|
|
87
|
+
|
|
62
88
|
/** One row or column heading in a grid. */
|
|
63
89
|
export interface ComparisonAxisItem {
|
|
64
90
|
key: string;
|
|
@@ -189,6 +215,10 @@ export interface SectionDef<TId extends string = string> {
|
|
|
189
215
|
/** Width class for this page's specimens. Without it, full-width (`itemsFill`) slots are 'wide'
|
|
190
216
|
* and everything else is 'regular'. */
|
|
191
217
|
specimenSize?: SpecimenSize;
|
|
218
|
+
/** Background stage behind every live component specimen on this page (list/grouped/grid cells,
|
|
219
|
+
* and a `render()` preview that isn't a token gallery). Defaults to `'auto'`. See
|
|
220
|
+
* `SpecimenSurfaceKind`. */
|
|
221
|
+
specimenSurface?: SpecimenSurfaceKind;
|
|
192
222
|
/** Widths for this page's `render()` preview, e.g. `[402, 320]` to add a small-phone example.
|
|
193
223
|
* Defaults to the catalog's default (CatalogShell `defaultPreviewWidths`), else `[402]`. */
|
|
194
224
|
previewWidths?: PreviewWidths;
|
|
@@ -203,6 +233,10 @@ export interface SectionDef<TId extends string = string> {
|
|
|
203
233
|
props?: boolean;
|
|
204
234
|
accessibility?: boolean;
|
|
205
235
|
};
|
|
236
|
+
/** What this component is built from or composed with — shown as a "Composition" subsection in
|
|
237
|
+
* Quick reference. See `ComposedOfEntry`. Omit when the component has no meaningful composition
|
|
238
|
+
* to disclose (most leaf components). */
|
|
239
|
+
composedOf?: ComposedOfEntry[];
|
|
206
240
|
}
|
|
207
241
|
|
|
208
242
|
/** A labeled group of section ids in the sidebar (e.g. "Components" vs "Tokens"). */
|