@krapwoo/ds-viewer 0.4.3 → 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 +62 -3
- package/dist/cli/explain.js +13 -8
- package/dist/cli/staticPage.js +20 -6
- package/dist/native/catalog/comparison.js +18 -6
- 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 +21 -15
- package/native/catalog/SpecimenSurface.tsx +46 -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/specimenSurfaceStyle.ts +15 -0
- package/native/catalog/types.ts +36 -0
- package/package.json +6 -1
- package/starter-kit/pages/Motion.catalog.tsx +48 -20
package/README.md
CHANGED
|
@@ -96,6 +96,12 @@ npm run check:catalog # the same headless check CI runs — every page, zero
|
|
|
96
96
|
documenting the viewer framework's own pieces (`CatalogShell`, `SectionBlock`, `ComparisonGrid`,
|
|
97
97
|
…), shown under a "Viewer" group alongside the starter kit's own pages.
|
|
98
98
|
|
|
99
|
+
## Contributing
|
|
100
|
+
|
|
101
|
+
Found a bug or an improvement while using ds-viewer in your app? Open an issue or a pull request:
|
|
102
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) explains how to try a change in your own app before opening
|
|
103
|
+
one, and what every pull request needs.
|
|
104
|
+
|
|
99
105
|
## Picking the right component
|
|
100
106
|
|
|
101
107
|
Several components look alike but solve different problems (InputField vs. SearchField vs.
|
|
@@ -184,8 +190,28 @@ actually apply:
|
|
|
184
190
|
within what fits a 1280px laptop: 5 compact, 3 regular, or 2 wide columns. `states` items whose
|
|
185
191
|
key matches a row or column key are not repeated below the grid.
|
|
186
192
|
- `specimenSize: 'compact' | 'regular' | 'wide'` — the page's specimen width class.
|
|
193
|
+
- `specimenSurface: 'neutral' | 'white' | 'dark' | 'transparent'` — the catalog-owned background
|
|
194
|
+
behind every live specimen on this page (list/grouped/grid cells, and a non-token `render()`
|
|
195
|
+
preview). Defaults to `'neutral'` (a light gray stage, visibly distinct from the surrounding
|
|
196
|
+
white card) — reach for `'dark'` to check a low-contrast component against a dark background, or
|
|
197
|
+
`'transparent'` when the surface itself would be misleading (e.g. a component whose own
|
|
198
|
+
elevation/shadow needs to read against the page, not a chip). Token galleries and sections always
|
|
199
|
+
keep their current, unstaged presentation regardless of this setting.
|
|
200
|
+
- `variants`/`states`' own `maxColumns: 1 | 2 | 3 | 4 | 5` — caps how many columns that slot's list
|
|
201
|
+
ever wraps into (e.g. a 3-item size scale that should always read as one row of exactly 3, never
|
|
202
|
+
more just because a laptop is wide). A narrow window still drops below the cap when it has to —
|
|
203
|
+
`maxColumns` only ever lowers the natural, width-based column count, never raises it.
|
|
187
204
|
- `group` on a `states` item — the `variants` key it belongs to (a size that only exists for one
|
|
188
205
|
variant). Grouped states render one row per variant; ungrouped ones go to "Other configurations".
|
|
206
|
+
- `composedOf: [{ component, role, relationship }]` — what this component is built from or
|
|
207
|
+
composed with, shown as a "Composition" subsection in Quick reference. `relationship` is
|
|
208
|
+
`'built-in'` for a component always present inside this one (e.g. Toast is built from a Banner),
|
|
209
|
+
`'slot'` for an optional caller-supplied child (e.g. a `leadingIcon` prop), or `'related'` for a
|
|
210
|
+
component commonly used alongside this one without either containing the other. `component` should be
|
|
211
|
+
a real component or page id: while you browse the catalog in development, an unknown name logs a
|
|
212
|
+
`[Catalog]` warning in the browser console. `doctor` doesn't check it, and it never fails a
|
|
213
|
+
build — e.g.
|
|
214
|
+
`composedOf: [{ component: 'Icon', role: 'Leading glyph', relationship: 'slot' }]`.
|
|
189
215
|
- `previewWidths: [402, 320]` — extra preview widths for a `render()` page (each capped at 402).
|
|
190
216
|
`CatalogShell`'s `defaultPreviewWidths="full"` keeps a catalog's previews full width.
|
|
191
217
|
- `variants: { desc?, align?, itemsFill?, items: [{ key, name, node }] }` — one item per prop enum
|
|
@@ -208,9 +234,18 @@ path. For anything beyond a single list of tokens, use the token-page layouts be
|
|
|
208
234
|
|
|
209
235
|
### Organising the catalog
|
|
210
236
|
|
|
211
|
-
-
|
|
212
|
-
|
|
213
|
-
|
|
237
|
+
- **`group`/`groupOrder` accept any semantic category you want** — not just `Tokens` and
|
|
238
|
+
`Components`. A page's `group` is a plain string shown as its sidebar section label;
|
|
239
|
+
`groupOrder` (e.g. `['Tokens', 'Components', 'Patterns', 'Recipes', 'Experiences']`) only
|
|
240
|
+
controls which order those sections appear in, top to bottom — any group not listed sorts
|
|
241
|
+
alphabetically after the ones that are. This starter kit's own two groups (`Tokens`, then
|
|
242
|
+
`Components`) are one valid shape, not the only one: a larger catalog might add `Patterns`
|
|
243
|
+
(reusable combinations like a settings row), `Recipes` (worked end-to-end screens), or
|
|
244
|
+
`Experiences` (full flows) alongside them. Pages sort A–Z inside each group regardless of how
|
|
245
|
+
many groups you have. Avoid splitting `Components` into finer categories such as "Actions" or
|
|
246
|
+
"Status" purely to browse by — that makes readers guess where a component lives; search and A–Z
|
|
247
|
+
already find it. A distinct semantic group (Tokens vs. Components vs. Patterns) is a different
|
|
248
|
+
decision from that, since it changes what the reader expects to find there.
|
|
214
249
|
- **Wide components at 3 columns.** Give block-level components (Banner, Card, Toast,
|
|
215
250
|
InputField) `specimenSize: 'regular'`, so Variants and States sit 3 across on a laptop
|
|
216
251
|
(about 316px each). `'wide'` (2 columns, phone width) is only for pages where the full phone
|
|
@@ -242,6 +277,7 @@ shape:
|
|
|
242
277
|
| Narrow sample with a long note (icon size, spacing) | `TokenRow` with `notePlacement="right"`: one line per token | IconSizes |
|
|
243
278
|
| Several kinds of token on one page | `tokenSections`: one titled card per kind | Colors (semantic vs palette) |
|
|
244
279
|
| A scale with families (type) | `tokenSections` with `tokenColumns: 2` or `3`, one section per family | Typography (label, body, emphasis…) |
|
|
280
|
+
| A duration/easing or spring value | `MotionSpecimen` next to the token's value | Motion (duration, easing, spring) |
|
|
245
281
|
|
|
246
282
|
- **`tokenSections: [{ title, desc?, wide?, render }]`** replaces `render`. Each section is its
|
|
247
283
|
own titled card. Keep different kinds of token in different sections, never mixed: semantic roles
|
|
@@ -256,6 +292,29 @@ shape:
|
|
|
256
292
|
value already say it. If there's nothing grounded to say, leave the note out.
|
|
257
293
|
- **Label each sample with its name and real value** (`bodyMd · 16/22 · 400`, `medium · 12px`).
|
|
258
294
|
Use realistic sample text from the product, short enough to fit its column at the largest size.
|
|
295
|
+
- **`MotionSpecimen`** demonstrates a duration/easing or spring token as a real, bounded
|
|
296
|
+
start-to-end slide — a dot travels a fixed track once when it mounts, with a named Replay button
|
|
297
|
+
to play it again. Give it `kind: 'timing'` with `duration` (and optionally a real `Easing`
|
|
298
|
+
function, e.g. `Easing.bezier(...DS_MOTION_EASING.standard)`) for a duration/easing token, or
|
|
299
|
+
`kind: 'spring'` with a `spring` config shaped for `Animated.spring` (e.g. `DS_MOTION_SPRING`) for
|
|
300
|
+
a spring token. It respects the platform's reduce-motion preference automatically — with it on,
|
|
301
|
+
every play (including the initial one) shows the end state immediately instead of animating, so
|
|
302
|
+
it never needs its own per-page reduce-motion handling:
|
|
303
|
+
```tsx
|
|
304
|
+
<MotionSpecimen kind="timing" duration={DS_MOTION_DURATION.base} easing={Easing.bezier(...DS_MOTION_EASING.standard)} />
|
|
305
|
+
<MotionSpecimen kind="spring" spring={DS_MOTION_SPRING} />
|
|
306
|
+
```
|
|
307
|
+
A spring's `valueRange` says which domain its `spring` config's rest thresholds are scaled for,
|
|
308
|
+
so the specimen animates the same domain the real config does rather than always normalizing to
|
|
309
|
+
0–1. `'distance'` (default) is for a config tuned for pixel-space points, e.g. a `BottomSheet`'s
|
|
310
|
+
snap-point spring — the `Animated.Value` animates directly from `0` to the track's pixel
|
|
311
|
+
distance. `'unit'` is for a config whose production value is a normalized 0-to-1 progress or
|
|
312
|
+
blend factor — the `Animated.Value` animates `0` to `1` and is interpolated to the pixel
|
|
313
|
+
distance; `'distance'`'s pixel-scale thresholds would otherwise end a unit-valued spring before
|
|
314
|
+
it ever moves:
|
|
315
|
+
```tsx
|
|
316
|
+
<MotionSpecimen kind="spring" spring={UNIT_SPRING_CONFIG} valueRange="unit" />
|
|
317
|
+
```
|
|
259
318
|
|
|
260
319
|
## Keeping the catalog current
|
|
261
320
|
|
package/dist/cli/explain.js
CHANGED
|
@@ -11,10 +11,8 @@ function placeholderItem(item) {
|
|
|
11
11
|
/** A throwaway `SectionDef` built from a `StaticPage` (Task 3) — every specimen's `node` is `null`
|
|
12
12
|
* (design §4: specimen nodes are never evaluated), since none of the layout functions below ever
|
|
13
13
|
* look at it; only the counts/keys/sizes they do look at are carried over. Every field Task 3
|
|
14
|
-
* reads is carried through here, including `previewWidths`/`fullWidthLabel`/`hide`/`itemsFill
|
|
15
|
-
*
|
|
16
|
-
* viewer for any page that set one of them (25 kit pages set `itemsFill`; every `kit-host`
|
|
17
|
-
* Viewer page sets `hide`). */
|
|
14
|
+
* reads is carried through here, including `previewWidths`/`fullWidthLabel`/`hide`/`itemsFill`/
|
|
15
|
+
* `maxColumns` — dropping any of them makes `explain` report different geometry than the viewer. */
|
|
18
16
|
function toSectionDef(page) {
|
|
19
17
|
const comparison = page.comparison && {
|
|
20
18
|
rowLabel: page.comparison.rowLabel ?? '',
|
|
@@ -32,8 +30,8 @@ function toSectionDef(page) {
|
|
|
32
30
|
fullWidthLabel: page.fullWidthLabel,
|
|
33
31
|
specimenSize: page.specimenSize,
|
|
34
32
|
previewWidths: page.previewWidths,
|
|
35
|
-
variants: page.variantsItems ? { itemsFill: page.variantsItemsFill, items: page.variantsItems.map(placeholderItem) } : undefined,
|
|
36
|
-
states: page.statesItems ? { itemsFill: page.statesItemsFill, items: page.statesItems.map(placeholderItem) } : undefined,
|
|
33
|
+
variants: page.variantsItems ? { itemsFill: page.variantsItemsFill, maxColumns: page.variantsMaxColumns, items: page.variantsItems.map(placeholderItem) } : undefined,
|
|
34
|
+
states: page.statesItems ? { itemsFill: page.statesItemsFill, maxColumns: page.statesMaxColumns, items: page.statesItems.map(placeholderItem) } : undefined,
|
|
37
35
|
comparison,
|
|
38
36
|
render: page.hasRender ? () => null : undefined,
|
|
39
37
|
tokenSections: page.tokenSectionTitles?.map((title, i) => ({ title, wide: page.tokenSectionWide?.[i] || undefined, render: () => null })),
|
|
@@ -53,7 +51,7 @@ function explainBlock(block) {
|
|
|
53
51
|
};
|
|
54
52
|
}
|
|
55
53
|
if (block.kind === 'list') {
|
|
56
|
-
const geometry = listGeometry(block.items.length, MATRIX_LAYOUT.laptopContentWidth, block.size);
|
|
54
|
+
const geometry = listGeometry(block.items.length, MATRIX_LAYOUT.laptopContentWidth, block.size, block.maxColumns);
|
|
57
55
|
return {
|
|
58
56
|
kind: 'list', title: block.title,
|
|
59
57
|
reason: `${block.items.length} items (${block.size}) → ${geometry.columns} columns × ${geometry.rows} row${geometry.rows === 1 ? '' : 's'}` +
|
|
@@ -89,7 +87,14 @@ function explainBlock(block) {
|
|
|
89
87
|
* computed uniformly here for either role, since setting it on `second` is simply unused, not wrong. */
|
|
90
88
|
function toPlacementBlock(block, height, available) {
|
|
91
89
|
if (block.kind === 'list') {
|
|
92
|
-
return {
|
|
90
|
+
return {
|
|
91
|
+
kind: 'list',
|
|
92
|
+
height,
|
|
93
|
+
itemCount: block.items.length,
|
|
94
|
+
size: block.size,
|
|
95
|
+
maxColumns: block.maxColumns,
|
|
96
|
+
width: listGeometry(block.items.length, available, block.size, block.maxColumns).containerWidth,
|
|
97
|
+
};
|
|
93
98
|
}
|
|
94
99
|
return { kind: block.kind, height };
|
|
95
100
|
}
|
package/dist/cli/staticPage.js
CHANGED
|
@@ -146,6 +146,7 @@ function readStaticPage(file) {
|
|
|
146
146
|
else {
|
|
147
147
|
page.variantsItems = variants.items;
|
|
148
148
|
page.variantsItemsFill = variants.itemsFill;
|
|
149
|
+
page.variantsMaxColumns = variants.maxColumns;
|
|
149
150
|
}
|
|
150
151
|
const states = readSlotItems(findProp(pageObject, 'states'), consts);
|
|
151
152
|
if (!states.checkable)
|
|
@@ -153,6 +154,7 @@ function readStaticPage(file) {
|
|
|
153
154
|
else {
|
|
154
155
|
page.statesItems = states.items;
|
|
155
156
|
page.statesItemsFill = states.itemsFill;
|
|
157
|
+
page.statesMaxColumns = states.maxColumns;
|
|
156
158
|
}
|
|
157
159
|
return page;
|
|
158
160
|
}
|
|
@@ -357,11 +359,12 @@ function readLiteralProps(node, consts) {
|
|
|
357
359
|
}
|
|
358
360
|
return result;
|
|
359
361
|
}
|
|
360
|
-
/** A `variants`/`states` field: `{ itemsFill?, items: [...] }`, `items` read by
|
|
361
|
-
* Distinguishes "field absent" (always checkable, everything undefined) from
|
|
362
|
-
* not literal" (not checkable). `itemsFill` is read only if present and
|
|
363
|
-
* `previewWidths`/`fullWidthLabel`, it isn't in
|
|
364
|
-
* non-literal `itemsFill` is simply left unset
|
|
362
|
+
/** A `variants`/`states` field: `{ itemsFill?, maxColumns?, items: [...] }`, `items` read by
|
|
363
|
+
* `readListItems`. Distinguishes "field absent" (always checkable, everything undefined) from
|
|
364
|
+
* "field present but not literal" (not checkable). `itemsFill` is read only if present and
|
|
365
|
+
* literal — like `previewWidths`/`fullWidthLabel`, it isn't in the original literal-checkable
|
|
366
|
+
* list, so a non-literal `itemsFill` is simply left unset. `maxColumns` does affect the geometry
|
|
367
|
+
* `explain` reports, so it must be a literal integer from 1–5 or the page is not checkable. */
|
|
365
368
|
function readSlotItems(prop, consts) {
|
|
366
369
|
if (!prop)
|
|
367
370
|
return { checkable: true };
|
|
@@ -374,5 +377,16 @@ function readSlotItems(prop, consts) {
|
|
|
374
377
|
return { checkable: false };
|
|
375
378
|
const itemsFillProp = ts.isObjectLiteralExpression(resolved) ? findProp(resolved, 'itemsFill') : undefined;
|
|
376
379
|
const itemsFill = itemsFillProp ? resolve(itemsFillProp.initializer, consts, new Set()).kind === ts.SyntaxKind.TrueKeyword : undefined;
|
|
377
|
-
|
|
380
|
+
const maxColumnsProp = ts.isObjectLiteralExpression(resolved) ? findProp(resolved, 'maxColumns') : undefined;
|
|
381
|
+
let maxColumns;
|
|
382
|
+
if (maxColumnsProp) {
|
|
383
|
+
const value = resolve(maxColumnsProp.initializer, consts, new Set());
|
|
384
|
+
if (!ts.isNumericLiteral(value))
|
|
385
|
+
return { checkable: false };
|
|
386
|
+
const parsed = Number(value.text);
|
|
387
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 5)
|
|
388
|
+
return { checkable: false };
|
|
389
|
+
maxColumns = parsed;
|
|
390
|
+
}
|
|
391
|
+
return { items, itemsFill, maxColumns, checkable: true };
|
|
378
392
|
}
|
|
@@ -75,13 +75,18 @@ export function gridWidthBounds(columnCount, size) {
|
|
|
75
75
|
/** Balanced wrapping for a one-axis list inside a single bordered card (1px outer border). Each
|
|
76
76
|
* cell's 1px divider sits inside its own width, so no cell is ever wider than 402px. Uses as few
|
|
77
77
|
* rows as fit, then spreads items evenly across them so a short last row leaves as little empty
|
|
78
|
-
* space as possible. `fillers` blank cells complete the last row.
|
|
79
|
-
|
|
78
|
+
* space as possible. `fillers` blank cells complete the last row. `columnCap` (from
|
|
79
|
+
* `VariantSlot.maxColumns`) only ever lowers the natural, width-based column count — a narrow
|
|
80
|
+
* container still drops below it when it has to, so the cap can never widen a row past what the
|
|
81
|
+
* available width actually fits. */
|
|
82
|
+
export function listGeometry(itemCount, availableWidth, size, columnCap) {
|
|
80
83
|
if (itemCount <= 0)
|
|
81
84
|
return { columns: 0, rows: 0, fillers: 0, cellWidth: 0, containerWidth: 0 };
|
|
82
85
|
const inner = Math.max(0, availableWidth - 2);
|
|
83
86
|
const min = COLUMN_MIN_WIDTH[size];
|
|
84
|
-
const
|
|
87
|
+
const widthLimit = Math.floor(inner / min);
|
|
88
|
+
const limit = columnCap !== undefined ? Math.min(widthLimit, columnCap) : widthLimit;
|
|
89
|
+
const maxColumns = Math.max(1, Math.min(itemCount, limit));
|
|
85
90
|
const rows = Math.ceil(itemCount / maxColumns);
|
|
86
91
|
const columns = Math.ceil(itemCount / rows);
|
|
87
92
|
const fitted = Math.floor(inner / columns);
|
|
@@ -241,7 +246,13 @@ export function presentationBlocks(def, options = {}) {
|
|
|
241
246
|
states = grouped.leftovers;
|
|
242
247
|
}
|
|
243
248
|
else if (def.variants) {
|
|
244
|
-
blocks.push({
|
|
249
|
+
blocks.push({
|
|
250
|
+
kind: 'list',
|
|
251
|
+
title: 'Variants',
|
|
252
|
+
size: slotSize(def, def.variants),
|
|
253
|
+
items: slotItems(def.variants),
|
|
254
|
+
...(def.variants.maxColumns !== undefined ? { maxColumns: def.variants.maxColumns } : {}),
|
|
255
|
+
});
|
|
245
256
|
}
|
|
246
257
|
else if (def.render) {
|
|
247
258
|
blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths) });
|
|
@@ -254,6 +265,7 @@ export function presentationBlocks(def, options = {}) {
|
|
|
254
265
|
title: secondary ? 'Other configurations' : 'States / configurations',
|
|
255
266
|
size: slotSize(def, states),
|
|
256
267
|
items: slotItems(states),
|
|
268
|
+
...(states.maxColumns !== undefined ? { maxColumns: states.maxColumns } : {}),
|
|
257
269
|
});
|
|
258
270
|
}
|
|
259
271
|
if (blocks.length === 0 && !hide.variants && !hide.states) {
|
|
@@ -274,8 +286,8 @@ export function choosePlacement({ available, gap, first, second }) {
|
|
|
274
286
|
const minCell = second.size === 'wide' ? MATRIX_LAYOUT.columnMaxWidth : COLUMN_MIN_WIDTH[second.size];
|
|
275
287
|
if (room < minCell + 2)
|
|
276
288
|
return 'stacked';
|
|
277
|
-
const stackedRows = listGeometry(second.itemCount, available, second.size).rows;
|
|
278
|
-
const sideRows = listGeometry(second.itemCount, room, second.size).rows;
|
|
289
|
+
const stackedRows = listGeometry(second.itemCount, available, second.size, second.maxColumns).rows;
|
|
290
|
+
const sideRows = listGeometry(second.itemCount, room, second.size, second.maxColumns).rows;
|
|
279
291
|
const rowHeight = second.height / Math.max(1, stackedRows);
|
|
280
292
|
const sideHeight = Math.max(first.height, sideRows * rowHeight);
|
|
281
293
|
const stackedHeight = first.height + gap + second.height;
|
|
@@ -2,14 +2,26 @@ 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, axisItems, cellKey, gridWidthBounds, indexCells, validateComparison } from './comparison';
|
|
5
|
-
import
|
|
5
|
+
import { SpecimenSurface } from './SpecimenSurface';
|
|
6
|
+
import type { ComparisonDef, SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* A grid of two props that combine freely, rendered as an accessible table inside one card.
|
|
9
10
|
* Columns grow with the window up to 402px and never shrink below the specimen size's minimum;
|
|
10
|
-
* a grid wider than its container scrolls horizontally inside the card, never the page.
|
|
11
|
+
* a grid wider than its container scrolls horizontally inside the card, never the page. `surface`
|
|
12
|
+
* is the page's `specimenSurface` behind each cell's example.
|
|
11
13
|
*/
|
|
12
|
-
export function ComparisonGrid({
|
|
14
|
+
export function ComparisonGrid({
|
|
15
|
+
def,
|
|
16
|
+
size,
|
|
17
|
+
sectionId,
|
|
18
|
+
surface = 'neutral',
|
|
19
|
+
}: {
|
|
20
|
+
def: ComparisonDef;
|
|
21
|
+
size: SpecimenSize;
|
|
22
|
+
sectionId: string;
|
|
23
|
+
surface?: SpecimenSurfaceKind;
|
|
24
|
+
}) {
|
|
13
25
|
if (__DEV__) {
|
|
14
26
|
for (const issue of validateComparison({ ...def, size })) console.warn(`[Catalog] ${sectionId}: ${issue}`);
|
|
15
27
|
}
|
|
@@ -53,7 +65,9 @@ export function ComparisonGrid({ def, size, sectionId }: { def: ComparisonDef; s
|
|
|
53
65
|
return (
|
|
54
66
|
<View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
|
|
55
67
|
{cell?.node !== undefined ? (
|
|
56
|
-
<
|
|
68
|
+
<SpecimenSurface surface={surface} style={cell.fill && styles.fill}>
|
|
69
|
+
{cell.node}
|
|
70
|
+
</SpecimenSurface>
|
|
57
71
|
) : (
|
|
58
72
|
<Text style={styles.unavailable}>
|
|
59
73
|
{cell?.unavailableReason ? `Not supported — ${cell.unavailableReason}` : 'Missing example'}
|
|
@@ -88,7 +102,6 @@ const styles = StyleSheet.create({
|
|
|
88
102
|
headerCell: { backgroundColor: CATALOG_COLOR.surfaceMuted, justifyContent: 'center' },
|
|
89
103
|
bodyRow: { minHeight: MATRIX_LAYOUT.rowMinHeight },
|
|
90
104
|
specimenCell: { alignItems: 'center', justifyContent: 'center' },
|
|
91
|
-
center: { alignItems: 'center' },
|
|
92
105
|
fill: { alignSelf: 'stretch' },
|
|
93
106
|
headerText: { fontSize: CATALOG_TYPE.tableHeader, fontWeight: '800', letterSpacing: 0.44, color: CATALOG_COLOR.text },
|
|
94
107
|
rowHeaderText: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
|
|
@@ -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,9 +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';
|
|
8
9
|
import { TokenSections } from './TokenLayouts';
|
|
9
10
|
import { choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
|
|
10
|
-
import type { PreviewWidths, SectionDef } from './types';
|
|
11
|
+
import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
|
|
11
12
|
|
|
12
13
|
// Matches a quoted-string-literal union type, e.g. "'primary' | 'secondary' | 'tertiary'" — anything
|
|
13
14
|
// else (string, boolean, IconName, () => void, …) has no fixed enum to sweep and is skipped.
|
|
@@ -100,9 +101,11 @@ function frameLabel(width: number): string {
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
/** 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
|
-
|
|
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>;
|
|
106
109
|
return (
|
|
107
110
|
<View style={styles.previewCard}>
|
|
108
111
|
<View style={styles.frames}>
|
|
@@ -114,7 +117,7 @@ function Preview({ render, widths }: { render: () => React.ReactNode; widths: Pr
|
|
|
114
117
|
style={[styles.frame, { width }]}
|
|
115
118
|
>
|
|
116
119
|
{widths.length > 1 && <Text style={styles.frameLabel}>{frameLabel(width)}</Text>}
|
|
117
|
-
{render()}
|
|
120
|
+
{stage(render())}
|
|
118
121
|
</View>
|
|
119
122
|
))}
|
|
120
123
|
</View>
|
|
@@ -122,16 +125,18 @@ function Preview({ render, widths }: { render: () => React.ReactNode; widths: Pr
|
|
|
122
125
|
);
|
|
123
126
|
}
|
|
124
127
|
|
|
125
|
-
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 }) {
|
|
126
129
|
switch (block.kind) {
|
|
127
130
|
case 'grid':
|
|
128
|
-
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} />;
|
|
129
132
|
case 'grouped':
|
|
130
|
-
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} />;
|
|
131
134
|
case 'list':
|
|
132
|
-
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} />;
|
|
133
136
|
case 'preview':
|
|
134
|
-
|
|
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;
|
|
135
140
|
case 'tokenSections':
|
|
136
141
|
return <TokenSections sections={block.sections} columns={block.columns} />;
|
|
137
142
|
default:
|
|
@@ -145,7 +150,7 @@ function BlockContent<TId extends string>({ block, def }: { block: PresentationB
|
|
|
145
150
|
|
|
146
151
|
/** Width a block's card occupies: lists hug their columns; everything else fills the row. */
|
|
147
152
|
function blockWidth(block: PresentationBlock, available: number): number {
|
|
148
|
-
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;
|
|
149
154
|
}
|
|
150
155
|
|
|
151
156
|
/**
|
|
@@ -154,7 +159,7 @@ function blockWidth(block: PresentationBlock, available: number): number {
|
|
|
154
159
|
* are never recorded while side by side, so the decision never feeds on its own result; a width
|
|
155
160
|
* change returns to stacked and decides again.
|
|
156
161
|
*/
|
|
157
|
-
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 }) {
|
|
158
163
|
const [available, setAvailable] = useState(0);
|
|
159
164
|
const [heights, setHeights] = useState<number[]>([]);
|
|
160
165
|
const [placement, setPlacement] = useState<'side' | 'stacked'>('stacked');
|
|
@@ -187,7 +192,7 @@ function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock
|
|
|
187
192
|
gap: CATALOG_LAYOUT.blockGap,
|
|
188
193
|
first: { kind: first.kind, width: blockWidth(first, available), height: heights[0] },
|
|
189
194
|
second: second.kind === 'list'
|
|
190
|
-
? { 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 }
|
|
191
196
|
: { kind: second.kind, height: heights[1] },
|
|
192
197
|
});
|
|
193
198
|
if (decision === 'side') setPlacement('side');
|
|
@@ -203,7 +208,7 @@ function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock
|
|
|
203
208
|
style={side ? (i === 0 ? { width: blockWidth(block, available), flexShrink: 0 } : styles.blockFill) : undefined}
|
|
204
209
|
>
|
|
205
210
|
{block.title ? <Text style={styles.blockLabel}>{block.title}</Text> : null}
|
|
206
|
-
<BlockContent block={block} def={def} />
|
|
211
|
+
<BlockContent block={block} def={def} surface={surface} />
|
|
207
212
|
</View>
|
|
208
213
|
))}
|
|
209
214
|
</View>
|
|
@@ -241,6 +246,7 @@ export function SectionBlock<TId extends string>({
|
|
|
241
246
|
checkCompleteness(def);
|
|
242
247
|
// Memoized so Blocks' placement effect runs on real changes, not on every render.
|
|
243
248
|
const blocks = useMemo(() => presentationBlocks(def, { defaultPreviewWidths }), [def, defaultPreviewWidths]);
|
|
249
|
+
const surface = def.specimenSurface ?? 'neutral';
|
|
244
250
|
// react-native-web reads `aria-level`; React Native's prop types do not declare it.
|
|
245
251
|
const headingLevelProps = { 'aria-level': headingLevel } as Record<string, unknown>;
|
|
246
252
|
|
|
@@ -271,7 +277,7 @@ export function SectionBlock<TId extends string>({
|
|
|
271
277
|
)}
|
|
272
278
|
</View>
|
|
273
279
|
|
|
274
|
-
{blocks.length > 0 && <Blocks blocks={blocks} def={def} />}
|
|
280
|
+
{blocks.length > 0 && <Blocks blocks={blocks} def={def} surface={surface} />}
|
|
275
281
|
|
|
276
282
|
<ReferenceDetails def={def} />
|
|
277
283
|
</View>
|
|
@@ -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
|
+
});
|
|
@@ -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,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SpecimenSurface`'s width policy, kept free of any `react-native` import so `node --test` can
|
|
3
|
+
* assert on it directly (see `native/catalog/__tests__/specimenSurfaceStyle.test.ts`).
|
|
4
|
+
*
|
|
5
|
+
* The surface always stretches to the full cross-axis width of whatever wraps it (a comparison
|
|
6
|
+
* cell, typically centered) rather than shrink-wrapping to its own content — `alignSelf: 'stretch'`
|
|
7
|
+
* overrides a centered parent's `alignItems` for this one child. A compact specimen (e.g. Button)
|
|
8
|
+
* still reads as centered, via `SpecimenSurface`'s own `alignItems`/`justifyContent: 'center'` on
|
|
9
|
+
* the now-full-width surface. Before this, a percentage-width child (Divider's `width: '100%'`, a
|
|
10
|
+
* phone mockup's percentage-width content) resolved against the surface's own shrink-wrapped,
|
|
11
|
+
* intrinsic width inside a centered comparison cell, and rendered at 0px.
|
|
12
|
+
*/
|
|
13
|
+
export const SPECIMEN_SURFACE_WIDTH_STYLE = {
|
|
14
|
+
alignSelf: 'stretch',
|
|
15
|
+
} as const;
|
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,28 @@ 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
|
+
/** Background behind a live component specimen (the list/grouped/grid cell, or the preview frame)
|
|
69
|
+
* — a neutral (default), explicit white, dark, or transparent catalog-owned stage, so a specimen
|
|
70
|
+
* reads as visibly separate from the surrounding white card rather than floating on it, which
|
|
71
|
+
* matters most for a low-contrast or motion-only component that could otherwise blend into a
|
|
72
|
+
* plain white background. Purely a fixed catalog-chrome backdrop: it never inspects or infers the
|
|
73
|
+
* wrapped component's own colors, and is never applied to token galleries/sections, which keep
|
|
74
|
+
* their current full-width, unstaged presentation regardless of this setting. */
|
|
75
|
+
export type SpecimenSurfaceKind = 'neutral' | 'white' | 'dark' | 'transparent';
|
|
76
|
+
|
|
77
|
+
/** One real component this page's component is built from or composed with — rendered as a
|
|
78
|
+
* "Composition" subsection in Quick reference. `relationship` is `'built-in'` for a component
|
|
79
|
+
* always present (e.g. Toast is built from a Banner internally), `'slot'` for an optional
|
|
80
|
+
* caller-supplied child (e.g. a `leadingIcon` prop), or `'related'` for a component commonly used
|
|
81
|
+
* alongside this one without either containing the other. In development, `buildCatalogSections`
|
|
82
|
+
* warns in the browser console when `component` isn't a known component or page id. `doctor`
|
|
83
|
+
* doesn't check it, and it never fails a build. */
|
|
84
|
+
export interface ComposedOfEntry {
|
|
85
|
+
component: string;
|
|
86
|
+
role: string;
|
|
87
|
+
relationship: 'built-in' | 'slot' | 'related';
|
|
88
|
+
}
|
|
89
|
+
|
|
62
90
|
/** One row or column heading in a grid. */
|
|
63
91
|
export interface ComparisonAxisItem {
|
|
64
92
|
key: string;
|
|
@@ -189,6 +217,10 @@ export interface SectionDef<TId extends string = string> {
|
|
|
189
217
|
/** Width class for this page's specimens. Without it, full-width (`itemsFill`) slots are 'wide'
|
|
190
218
|
* and everything else is 'regular'. */
|
|
191
219
|
specimenSize?: SpecimenSize;
|
|
220
|
+
/** Background stage behind every live component specimen on this page (list/grouped/grid cells,
|
|
221
|
+
* and a `render()` preview that isn't a token gallery). Defaults to `'neutral'`. See
|
|
222
|
+
* `SpecimenSurfaceKind`. */
|
|
223
|
+
specimenSurface?: SpecimenSurfaceKind;
|
|
192
224
|
/** Widths for this page's `render()` preview, e.g. `[402, 320]` to add a small-phone example.
|
|
193
225
|
* Defaults to the catalog's default (CatalogShell `defaultPreviewWidths`), else `[402]`. */
|
|
194
226
|
previewWidths?: PreviewWidths;
|
|
@@ -203,6 +235,10 @@ export interface SectionDef<TId extends string = string> {
|
|
|
203
235
|
props?: boolean;
|
|
204
236
|
accessibility?: boolean;
|
|
205
237
|
};
|
|
238
|
+
/** What this component is built from or composed with — shown as a "Composition" subsection in
|
|
239
|
+
* Quick reference. See `ComposedOfEntry`. Omit when the component has no meaningful composition
|
|
240
|
+
* to disclose (most leaf components). */
|
|
241
|
+
composedOf?: ComposedOfEntry[];
|
|
206
242
|
}
|
|
207
243
|
|
|
208
244
|
/** A labeled group of section ids in the sidebar (e.g. "Components" vs "Tokens"). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krapwoo/ds-viewer",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A browsable, always-current catalog for an Expo / React Native design system.",
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "https://github.com/krapwoo/design-system-viewer.git"
|
|
10
10
|
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/krapwoo/design-system-viewer/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/krapwoo/design-system-viewer#readme",
|
|
11
15
|
"engines": {
|
|
12
16
|
"node": ">=20.19.0"
|
|
13
17
|
},
|
|
@@ -57,6 +61,7 @@
|
|
|
57
61
|
},
|
|
58
62
|
"scripts": {
|
|
59
63
|
"build": "tsc -p tsconfig.json",
|
|
64
|
+
"prepack": "npm run build",
|
|
60
65
|
"test": "node --experimental-strip-types --test 'cli/**/*.test.ts' 'config/**/*.test.ts' 'native/catalog/__tests__/*.test.ts'",
|
|
61
66
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
62
67
|
"kit:dev": "npm run build && cd kit-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js dev",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { View, Text, StyleSheet } from 'react-native';
|
|
3
|
-
import { defineCatalogPage, DividedStack, VariantGroup, TokenRow } from '@krapwoo/ds-viewer';
|
|
2
|
+
import { View, Text, StyleSheet, Easing } from 'react-native';
|
|
3
|
+
import { defineCatalogPage, DividedStack, VariantGroup, TokenRow, MotionSpecimen } from '@krapwoo/ds-viewer';
|
|
4
4
|
import {
|
|
5
5
|
DS_MOTION_DURATION,
|
|
6
6
|
DS_MOTION_DURATION_STEPS,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
|
|
22
22
|
const styles = StyleSheet.create({
|
|
23
23
|
tokenStack: { gap: DS_SPACING[800] },
|
|
24
|
+
rowContent: { gap: DS_SPACING[400] },
|
|
24
25
|
previewRow: { flexDirection: 'row', alignItems: 'center', gap: DS_SPACING[600] },
|
|
25
26
|
previewLabel: { minWidth: 64, ...DS_TYPOGRAPHY.labelSm, color: DS_SEMANTIC.text.regular },
|
|
26
27
|
previewValue: { ...DS_TYPOGRAPHY.bodyXs, color: DS_SEMANTIC.text.muted },
|
|
@@ -41,10 +42,18 @@ function MotionGallery() {
|
|
|
41
42
|
<View style={styles.tokenStack}>
|
|
42
43
|
{DS_MOTION_DURATION_STEPS.map((step, i) => (
|
|
43
44
|
<TokenRow key={step} use={DS_MOTION_DURATION_USE[step]} last={i === DS_MOTION_DURATION_STEPS.length - 1}>
|
|
44
|
-
<View style={styles.
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
<View style={styles.rowContent}>
|
|
46
|
+
<View style={styles.previewRow}>
|
|
47
|
+
<Text style={styles.previewLabel}>{step}</Text>
|
|
48
|
+
<View style={[styles.durationBar, { width: (DS_MOTION_DURATION[step] / maxDuration) * 120 }]} />
|
|
49
|
+
<Text style={styles.previewValue}>{DS_MOTION_DURATION[step]}ms</Text>
|
|
50
|
+
</View>
|
|
51
|
+
<MotionSpecimen
|
|
52
|
+
kind="timing"
|
|
53
|
+
duration={DS_MOTION_DURATION[step]}
|
|
54
|
+
easing={Easing.bezier(...DS_MOTION_EASING.standard)}
|
|
55
|
+
label={`${DS_MOTION_DURATION[step]}ms · standard easing`}
|
|
56
|
+
/>
|
|
48
57
|
</View>
|
|
49
58
|
</TokenRow>
|
|
50
59
|
))}
|
|
@@ -54,10 +63,18 @@ function MotionGallery() {
|
|
|
54
63
|
<View style={styles.tokenStack}>
|
|
55
64
|
{DS_MOTION_LOOP_DURATION_STEPS.map((step, i) => (
|
|
56
65
|
<TokenRow key={step} use={DS_MOTION_LOOP_DURATION_USE[step]} last={i === DS_MOTION_LOOP_DURATION_STEPS.length - 1}>
|
|
57
|
-
<View style={styles.
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
<View style={styles.rowContent}>
|
|
67
|
+
<View style={styles.previewRow}>
|
|
68
|
+
<Text style={styles.previewLabel}>{step}</Text>
|
|
69
|
+
<View style={[styles.durationBar, { width: (DS_MOTION_LOOP_DURATION[step] / maxLoopDuration) * 120 }]} />
|
|
70
|
+
<Text style={styles.previewValue}>{DS_MOTION_LOOP_DURATION[step]}ms</Text>
|
|
71
|
+
</View>
|
|
72
|
+
<MotionSpecimen
|
|
73
|
+
kind="timing"
|
|
74
|
+
duration={DS_MOTION_LOOP_DURATION[step]}
|
|
75
|
+
easing={Easing.bezier(...DS_MOTION_EASING.standard)}
|
|
76
|
+
label={`${DS_MOTION_LOOP_DURATION[step]}ms · one cycle`}
|
|
77
|
+
/>
|
|
61
78
|
</View>
|
|
62
79
|
</TokenRow>
|
|
63
80
|
))}
|
|
@@ -67,9 +84,17 @@ function MotionGallery() {
|
|
|
67
84
|
<View style={styles.tokenStack}>
|
|
68
85
|
{DS_MOTION_EASING_STEPS.map((step, i) => (
|
|
69
86
|
<TokenRow key={step} use={DS_MOTION_EASING_USE[step]} last={i === DS_MOTION_EASING_STEPS.length - 1}>
|
|
70
|
-
<View style={styles.
|
|
71
|
-
<
|
|
72
|
-
|
|
87
|
+
<View style={styles.rowContent}>
|
|
88
|
+
<View style={styles.previewRow}>
|
|
89
|
+
<Text style={styles.previewLabel}>{step}</Text>
|
|
90
|
+
<Text style={styles.easingValue}>cubic-bezier({DS_MOTION_EASING[step].join(', ')})</Text>
|
|
91
|
+
</View>
|
|
92
|
+
<MotionSpecimen
|
|
93
|
+
kind="timing"
|
|
94
|
+
duration={DS_MOTION_DURATION.base}
|
|
95
|
+
easing={Easing.bezier(...DS_MOTION_EASING[step])}
|
|
96
|
+
label={`${DS_MOTION_DURATION.base}ms · ${step}`}
|
|
97
|
+
/>
|
|
73
98
|
</View>
|
|
74
99
|
</TokenRow>
|
|
75
100
|
))}
|
|
@@ -78,13 +103,16 @@ function MotionGallery() {
|
|
|
78
103
|
<VariantGroup name="Spring" desc="for Animated.spring(value, DS_MOTION_SPRING) — snap-point transitions, not fixed-duration timing">
|
|
79
104
|
<View style={styles.tokenStack}>
|
|
80
105
|
<TokenRow use={DS_MOTION_SPRING_USE} last>
|
|
81
|
-
<View style={styles.
|
|
82
|
-
{
|
|
83
|
-
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
106
|
+
<View style={styles.rowContent}>
|
|
107
|
+
<View style={styles.springGrid}>
|
|
108
|
+
{(Object.entries(DS_MOTION_SPRING) as [string, number | boolean][]).map(([key, value]) => (
|
|
109
|
+
<View key={key} style={styles.springField}>
|
|
110
|
+
<Text style={styles.previewLabel}>{key}</Text>
|
|
111
|
+
<Text style={styles.previewValue}>{String(value)}</Text>
|
|
112
|
+
</View>
|
|
113
|
+
))}
|
|
114
|
+
</View>
|
|
115
|
+
<MotionSpecimen kind="spring" spring={DS_MOTION_SPRING} label="Animated.spring(value, DS_MOTION_SPRING)" />
|
|
88
116
|
</View>
|
|
89
117
|
</TokenRow>
|
|
90
118
|
</View>
|