@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
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,30 @@ 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: 'auto' | 'neutral' | 'white' | 'dark' | 'transparent'` — the fill of each
|
|
194
|
+
example's cell (list, grouped and grid cells, and a non-token `render()` preview). Defaults to
|
|
195
|
+
`'auto'`: after the page draws, a cell turns light gray only when its own example would vanish
|
|
196
|
+
against white (its largest coloured box is near-white with no visible border, like a white card,
|
|
197
|
+
sheet or neutral banner; a shadow alone doesn't count). Neighbouring examples keep a white cell,
|
|
198
|
+
and most pages need nothing. Override it when the guess is wrong: `'neutral'` makes every example
|
|
199
|
+
cell gray, `'white'` or `'transparent'` keeps them all white, and `'dark'` checks a light
|
|
200
|
+
component on a dark fill. Detection runs in the browser viewer; elsewhere cells stay white.
|
|
201
|
+
Token galleries and sections are never filled.
|
|
202
|
+
- `variants`/`states`' own `maxColumns: 1 | 2 | 3 | 4 | 5` — caps how many columns that slot's list
|
|
203
|
+
ever wraps into (e.g. a 3-item size scale that should always read as one row of exactly 3, never
|
|
204
|
+
more just because a laptop is wide). A narrow window still drops below the cap when it has to —
|
|
205
|
+
`maxColumns` only ever lowers the natural, width-based column count, never raises it.
|
|
187
206
|
- `group` on a `states` item — the `variants` key it belongs to (a size that only exists for one
|
|
188
207
|
variant). Grouped states render one row per variant; ungrouped ones go to "Other configurations".
|
|
208
|
+
- `composedOf: [{ component, role, relationship }]` — what this component is built from or
|
|
209
|
+
composed with, shown as a "Composition" subsection in Quick reference. `relationship` is
|
|
210
|
+
`'built-in'` for a component always present inside this one (e.g. Toast is built from a Banner),
|
|
211
|
+
`'slot'` for an optional caller-supplied child (e.g. a `leadingIcon` prop), or `'related'` for a
|
|
212
|
+
component commonly used alongside this one without either containing the other. `component` should be
|
|
213
|
+
a real component or page id: while you browse the catalog in development, an unknown name logs a
|
|
214
|
+
`[Catalog]` warning in the browser console. `doctor` doesn't check it, and it never fails a
|
|
215
|
+
build — e.g.
|
|
216
|
+
`composedOf: [{ component: 'Icon', role: 'Leading glyph', relationship: 'slot' }]`.
|
|
189
217
|
- `previewWidths: [402, 320]` — extra preview widths for a `render()` page (each capped at 402).
|
|
190
218
|
`CatalogShell`'s `defaultPreviewWidths="full"` keeps a catalog's previews full width.
|
|
191
219
|
- `variants: { desc?, align?, itemsFill?, items: [{ key, name, node }] }` — one item per prop enum
|
|
@@ -208,9 +236,18 @@ path. For anything beyond a single list of tokens, use the token-page layouts be
|
|
|
208
236
|
|
|
209
237
|
### Organising the catalog
|
|
210
238
|
|
|
211
|
-
-
|
|
212
|
-
|
|
213
|
-
|
|
239
|
+
- **`group`/`groupOrder` accept any semantic category you want** — not just `Tokens` and
|
|
240
|
+
`Components`. A page's `group` is a plain string shown as its sidebar section label;
|
|
241
|
+
`groupOrder` (e.g. `['Tokens', 'Components', 'Patterns', 'Recipes', 'Experiences']`) only
|
|
242
|
+
controls which order those sections appear in, top to bottom — any group not listed sorts
|
|
243
|
+
alphabetically after the ones that are. This starter kit's own two groups (`Tokens`, then
|
|
244
|
+
`Components`) are one valid shape, not the only one: a larger catalog might add `Patterns`
|
|
245
|
+
(reusable combinations like a settings row), `Recipes` (worked end-to-end screens), or
|
|
246
|
+
`Experiences` (full flows) alongside them. Pages sort A–Z inside each group regardless of how
|
|
247
|
+
many groups you have. Avoid splitting `Components` into finer categories such as "Actions" or
|
|
248
|
+
"Status" purely to browse by — that makes readers guess where a component lives; search and A–Z
|
|
249
|
+
already find it. A distinct semantic group (Tokens vs. Components vs. Patterns) is a different
|
|
250
|
+
decision from that, since it changes what the reader expects to find there.
|
|
214
251
|
- **Wide components at 3 columns.** Give block-level components (Banner, Card, Toast,
|
|
215
252
|
InputField) `specimenSize: 'regular'`, so Variants and States sit 3 across on a laptop
|
|
216
253
|
(about 316px each). `'wide'` (2 columns, phone width) is only for pages where the full phone
|
|
@@ -242,6 +279,7 @@ shape:
|
|
|
242
279
|
| Narrow sample with a long note (icon size, spacing) | `TokenRow` with `notePlacement="right"`: one line per token | IconSizes |
|
|
243
280
|
| Several kinds of token on one page | `tokenSections`: one titled card per kind | Colors (semantic vs palette) |
|
|
244
281
|
| A scale with families (type) | `tokenSections` with `tokenColumns: 2` or `3`, one section per family | Typography (label, body, emphasis…) |
|
|
282
|
+
| A duration/easing or spring value | `MotionSpecimen` next to the token's value | Motion (duration, easing, spring) |
|
|
245
283
|
|
|
246
284
|
- **`tokenSections: [{ title, desc?, wide?, render }]`** replaces `render`. Each section is its
|
|
247
285
|
own titled card. Keep different kinds of token in different sections, never mixed: semantic roles
|
|
@@ -256,6 +294,29 @@ shape:
|
|
|
256
294
|
value already say it. If there's nothing grounded to say, leave the note out.
|
|
257
295
|
- **Label each sample with its name and real value** (`bodyMd · 16/22 · 400`, `medium · 12px`).
|
|
258
296
|
Use realistic sample text from the product, short enough to fit its column at the largest size.
|
|
297
|
+
- **`MotionSpecimen`** demonstrates a duration/easing or spring token as a real, bounded
|
|
298
|
+
start-to-end slide — a dot travels a fixed track once when it mounts, with a named Replay button
|
|
299
|
+
to play it again. Give it `kind: 'timing'` with `duration` (and optionally a real `Easing`
|
|
300
|
+
function, e.g. `Easing.bezier(...DS_MOTION_EASING.standard)`) for a duration/easing token, or
|
|
301
|
+
`kind: 'spring'` with a `spring` config shaped for `Animated.spring` (e.g. `DS_MOTION_SPRING`) for
|
|
302
|
+
a spring token. It respects the platform's reduce-motion preference automatically — with it on,
|
|
303
|
+
every play (including the initial one) shows the end state immediately instead of animating, so
|
|
304
|
+
it never needs its own per-page reduce-motion handling:
|
|
305
|
+
```tsx
|
|
306
|
+
<MotionSpecimen kind="timing" duration={DS_MOTION_DURATION.base} easing={Easing.bezier(...DS_MOTION_EASING.standard)} />
|
|
307
|
+
<MotionSpecimen kind="spring" spring={DS_MOTION_SPRING} />
|
|
308
|
+
```
|
|
309
|
+
A spring's `valueRange` says which domain its `spring` config's rest thresholds are scaled for,
|
|
310
|
+
so the specimen animates the same domain the real config does rather than always normalizing to
|
|
311
|
+
0–1. `'distance'` (default) is for a config tuned for pixel-space points, e.g. a `BottomSheet`'s
|
|
312
|
+
snap-point spring — the `Animated.Value` animates directly from `0` to the track's pixel
|
|
313
|
+
distance. `'unit'` is for a config whose production value is a normalized 0-to-1 progress or
|
|
314
|
+
blend factor — the `Animated.Value` animates `0` to `1` and is interpolated to the pixel
|
|
315
|
+
distance; `'distance'`'s pixel-scale thresholds would otherwise end a unit-valued spring before
|
|
316
|
+
it ever moves:
|
|
317
|
+
```tsx
|
|
318
|
+
<MotionSpecimen kind="spring" spring={UNIT_SPRING_CONFIG} valueRange="unit" />
|
|
319
|
+
```
|
|
259
320
|
|
|
260
321
|
## Keeping the catalog current
|
|
261
322
|
|
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;
|
|
@@ -111,6 +111,8 @@ export const CATALOG_COLOR = {
|
|
|
111
111
|
surfaceMuted: '#fafafa',
|
|
112
112
|
/** Hover and pressed feedback on nav rows and buttons. */
|
|
113
113
|
surfacePressed: '#f1f3f8',
|
|
114
|
+
/** An example's cell when the example itself is white (`specimenSurface`), darker than surfaceMuted so a white card's edges show. */
|
|
115
|
+
specimenStage: '#f2f2f2',
|
|
114
116
|
pageBackground: '#f6f6f4',
|
|
115
117
|
chip: '#eeeeee',
|
|
116
118
|
/** Active nav label, prop types, scale bars. */
|
|
@@ -2,14 +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, axisItems, cellKey, gridWidthBounds, indexCells, validateComparison } from './comparison';
|
|
5
|
-
import
|
|
5
|
+
import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
|
+
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
7
|
+
import type { ComparisonDef, SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* A grid of two props that combine freely, rendered as an accessible table inside one card.
|
|
9
11
|
* 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.
|
|
12
|
+
* a grid wider than its container scrolls horizontally inside the card, never the page. `surface`
|
|
13
|
+
* is the page's `specimenSurface` behind each cell's example.
|
|
11
14
|
*/
|
|
12
|
-
export function ComparisonGrid({
|
|
15
|
+
export function ComparisonGrid({
|
|
16
|
+
def,
|
|
17
|
+
size,
|
|
18
|
+
sectionId,
|
|
19
|
+
surface = DEFAULT_SPECIMEN_SURFACE,
|
|
20
|
+
}: {
|
|
21
|
+
def: ComparisonDef;
|
|
22
|
+
size: SpecimenSize;
|
|
23
|
+
sectionId: string;
|
|
24
|
+
surface?: SpecimenSurfaceKind;
|
|
25
|
+
}) {
|
|
13
26
|
if (__DEV__) {
|
|
14
27
|
for (const issue of validateComparison({ ...def, size })) console.warn(`[Catalog] ${sectionId}: ${issue}`);
|
|
15
28
|
}
|
|
@@ -51,15 +64,17 @@ export function ComparisonGrid({ def, size, sectionId }: { def: ComparisonDef; s
|
|
|
51
64
|
{columns.map((c, ci) => {
|
|
52
65
|
const cell = cells.get(cellKey(row.key, c.key));
|
|
53
66
|
return (
|
|
54
|
-
|
|
55
|
-
{cell
|
|
56
|
-
<
|
|
57
|
-
|
|
67
|
+
cell?.node !== undefined ? (
|
|
68
|
+
<SpecimenSurface key={c.key} role="cell" surface={surface} style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
|
|
69
|
+
<SpecimenContent fill={cell.fill}>{cell.node}</SpecimenContent>
|
|
70
|
+
</SpecimenSurface>
|
|
71
|
+
) : (
|
|
72
|
+
<View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
|
|
58
73
|
<Text style={styles.unavailable}>
|
|
59
74
|
{cell?.unavailableReason ? `Not supported — ${cell.unavailableReason}` : 'Missing example'}
|
|
60
75
|
</Text>
|
|
61
|
-
|
|
62
|
-
|
|
76
|
+
</View>
|
|
77
|
+
)
|
|
63
78
|
);
|
|
64
79
|
})}
|
|
65
80
|
</View>
|
|
@@ -88,8 +103,6 @@ const styles = StyleSheet.create({
|
|
|
88
103
|
headerCell: { backgroundColor: CATALOG_COLOR.surfaceMuted, justifyContent: 'center' },
|
|
89
104
|
bodyRow: { minHeight: MATRIX_LAYOUT.rowMinHeight },
|
|
90
105
|
specimenCell: { alignItems: 'center', justifyContent: 'center' },
|
|
91
|
-
center: { alignItems: 'center' },
|
|
92
|
-
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 },
|
|
95
108
|
unavailable: { fontSize: CATALOG_TYPE.sm, fontStyle: 'italic', textAlign: 'center', color: CATALOG_COLOR.textMuted },
|
|
@@ -2,15 +2,28 @@ 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 { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
|
+
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
7
|
+
import type { SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Grouped rows: one row per variant, headed by the variant's name, holding that variant's own
|
|
9
11
|
* configurations. Each cell keeps its own caption because the rows do not share column meanings
|
|
10
12
|
* (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.
|
|
13
|
+
* in blank cells; a card wider than its container scrolls horizontally inside itself. `surface` is
|
|
14
|
+
* the page's `specimenSurface` behind each item.
|
|
12
15
|
*/
|
|
13
|
-
export function ComparisonGroups({
|
|
16
|
+
export function ComparisonGroups({
|
|
17
|
+
groups,
|
|
18
|
+
size,
|
|
19
|
+
label,
|
|
20
|
+
surface = DEFAULT_SPECIMEN_SURFACE,
|
|
21
|
+
}: {
|
|
22
|
+
groups: ListGroup[];
|
|
23
|
+
size: SpecimenSize;
|
|
24
|
+
label: string;
|
|
25
|
+
surface?: SpecimenSurfaceKind;
|
|
26
|
+
}) {
|
|
14
27
|
const columns = Math.max(1, ...groups.map((g) => g.items.length));
|
|
15
28
|
const min = COLUMN_MIN_WIDTH[size];
|
|
16
29
|
const minWidth = MATRIX_LAYOUT.rowHeaderWidth + columns * min + 2;
|
|
@@ -34,9 +47,9 @@ export function ComparisonGroups({ groups, size, label }: { groups: ListGroup[];
|
|
|
34
47
|
<View style={styles.caption}>
|
|
35
48
|
<Text style={styles.captionText}>{item.label}</Text>
|
|
36
49
|
</View>
|
|
37
|
-
<
|
|
38
|
-
<
|
|
39
|
-
</
|
|
50
|
+
<SpecimenSurface surface={surface} style={styles.specimen}>
|
|
51
|
+
<SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
|
|
52
|
+
</SpecimenSurface>
|
|
40
53
|
</View>
|
|
41
54
|
);
|
|
42
55
|
})}
|
|
@@ -88,6 +101,4 @@ const styles = StyleSheet.create({
|
|
|
88
101
|
alignItems: 'center',
|
|
89
102
|
justifyContent: 'center',
|
|
90
103
|
},
|
|
91
|
-
center: { alignItems: 'center' },
|
|
92
|
-
fill: { alignSelf: 'stretch' },
|
|
93
104
|
});
|
|
@@ -2,16 +2,32 @@ 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 { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
|
+
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
7
|
+
import type { SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* One-axis examples inside ONE shared card. Cells wrap into balanced rows (listGeometry); each cell
|
|
9
11
|
* 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.
|
|
12
|
+
* last row. Wide specimens use fixed 402px cells and the card hugs its columns. `maxColumns` caps
|
|
13
|
+
* how many columns a row ever wraps into (from `VariantSlot.maxColumns`); `surface` is the page's
|
|
14
|
+
* `specimenSurface` behind each item.
|
|
11
15
|
*/
|
|
12
|
-
export function ComparisonList({
|
|
16
|
+
export function ComparisonList({
|
|
17
|
+
items,
|
|
18
|
+
size,
|
|
19
|
+
label,
|
|
20
|
+
maxColumns,
|
|
21
|
+
surface = DEFAULT_SPECIMEN_SURFACE,
|
|
22
|
+
}: {
|
|
23
|
+
items: ListItem[];
|
|
24
|
+
size: SpecimenSize;
|
|
25
|
+
label: string;
|
|
26
|
+
maxColumns?: 1 | 2 | 3 | 4 | 5;
|
|
27
|
+
surface?: SpecimenSurfaceKind;
|
|
28
|
+
}) {
|
|
13
29
|
const [available, setAvailable] = useState<number>(MATRIX_LAYOUT.laptopContentWidth);
|
|
14
|
-
const geometry = listGeometry(items.length, available, size);
|
|
30
|
+
const geometry = listGeometry(items.length, available, size, maxColumns);
|
|
15
31
|
const onLayout = (event: LayoutChangeEvent) => {
|
|
16
32
|
const width = Math.round(event.nativeEvent.layout.width);
|
|
17
33
|
if (width > 0 && width !== available) setAvailable(width);
|
|
@@ -45,9 +61,9 @@ export function ComparisonList({ items, size, label }: { items: ListItem[]; size
|
|
|
45
61
|
<View style={styles.caption}>
|
|
46
62
|
<Text style={styles.captionText}>{item.label}</Text>
|
|
47
63
|
</View>
|
|
48
|
-
<
|
|
49
|
-
<
|
|
50
|
-
</
|
|
64
|
+
<SpecimenSurface surface={surface} style={styles.specimen}>
|
|
65
|
+
<SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
|
|
66
|
+
</SpecimenSurface>
|
|
51
67
|
</View>
|
|
52
68
|
);
|
|
53
69
|
})}
|
|
@@ -84,6 +100,4 @@ const styles = StyleSheet.create({
|
|
|
84
100
|
alignItems: 'center',
|
|
85
101
|
justifyContent: 'center',
|
|
86
102
|
},
|
|
87
|
-
center: { alignItems: 'center' },
|
|
88
|
-
fill: { alignSelf: 'stretch' },
|
|
89
103
|
});
|
|
@@ -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',
|