@krapwoo/ds-viewer 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -1
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/explain.js +29 -8
- package/dist/cli/props.js +20 -5
- package/dist/cli/staticPage.js +42 -6
- package/dist/cli/sync.js +2 -1
- package/dist/native/catalog/catalogNavigation.js +4 -2
- package/dist/native/catalog/comparison.js +22 -6
- package/dist/native/catalog/tokenLayout.js +26 -0
- package/native/catalog/CatalogShell.tsx +3 -1
- package/native/catalog/CatalogSidebar.tsx +7 -4
- package/native/catalog/ComparisonGrid.tsx +18 -5
- package/native/catalog/ComparisonGroups.tsx +18 -5
- package/native/catalog/ComparisonList.tsx +22 -6
- package/native/catalog/MotionSpecimen.tsx +152 -0
- package/native/catalog/ReferenceDetails.tsx +15 -0
- package/native/catalog/SectionBlock.tsx +50 -21
- package/native/catalog/SpecimenSurface.tsx +46 -0
- package/native/catalog/TokenLayouts.tsx +92 -0
- package/native/catalog/TokenRow.tsx +30 -5
- package/native/catalog/catalogNavigation.ts +7 -2
- package/native/catalog/comparison.ts +28 -9
- package/native/catalog/index.ts +8 -1
- package/native/catalog/motionValueDomain.ts +37 -0
- package/native/catalog/pageApi.ts +38 -3
- package/native/catalog/specimenSurfaceStyle.ts +15 -0
- package/native/catalog/tokenLayout.ts +29 -0
- package/native/catalog/types.ts +62 -0
- package/package.json +6 -1
- package/starter-kit/pages/Motion.catalog.tsx +48 -20
|
@@ -48,12 +48,17 @@ export function neighbors<TId extends string>(
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed. */
|
|
51
|
-
export function filterGroups<TId extends string>(
|
|
51
|
+
export function filterGroups<TId extends string>(
|
|
52
|
+
groups: readonly NavGroup<TId>[],
|
|
53
|
+
query: string,
|
|
54
|
+
/** Display titles by id; a page matches on its id or its title. */
|
|
55
|
+
labels?: ReadonlyMap<string, string>,
|
|
56
|
+
): NavGroup<TId>[] {
|
|
52
57
|
const q = query.trim().toLowerCase();
|
|
53
58
|
return groups
|
|
54
59
|
.map((group) => ({
|
|
55
60
|
label: group.label,
|
|
56
|
-
ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q)) : group.ids),
|
|
61
|
+
ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q) || (labels?.get(id)?.toLowerCase().includes(q) ?? false)) : group.ids),
|
|
57
62
|
}))
|
|
58
63
|
.filter((group) => group.ids.length > 0);
|
|
59
64
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* It also decides whether two blocks sit side by side (choosePlacement) and how many columns the
|
|
9
9
|
* Props box uses (propsColumns). ComparisonGrid, ComparisonGroups, and ComparisonList render it.
|
|
10
10
|
*/
|
|
11
|
-
import type { ComparisonAxisItem, ComparisonDef, GridAxis, PreviewWidths, SectionDef, SpecimenSize, VariantSlot } from './types.ts';
|
|
11
|
+
import type { ComparisonAxisItem, ComparisonDef, GridAxis, PreviewWidths, SectionDef, SpecimenSize, TokenSection, VariantSlot } from './types.ts';
|
|
12
12
|
import type React from 'react';
|
|
13
13
|
|
|
14
14
|
/** Approved geometry. `cellPadding` equals CATALOG_SPACE.lg (asserted in tests). The laptop
|
|
@@ -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,8 +233,9 @@ 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 }
|
|
238
|
+
| { kind: 'tokenSections'; title: string; sections: TokenSection[]; columns: 1 | 2 | 3 }
|
|
233
239
|
| { kind: 'empty'; title: string; message: string };
|
|
234
240
|
|
|
235
241
|
export interface PresentationOptions {
|
|
@@ -280,6 +286,10 @@ function groupStates(variants: VariantSlot, states: VariantSlot): { groups: List
|
|
|
280
286
|
* never shows an empty block next to a real one; with nothing documented at all it shows one
|
|
281
287
|
* "No examples documented." block. */
|
|
282
288
|
export function presentationBlocks<TId extends string>(def: SectionDef<TId>, options: PresentationOptions = {}): PresentationBlock[] {
|
|
289
|
+
if (def.tokenSections && def.tokenSections.length > 0) {
|
|
290
|
+
// Each section carries its own label, so the block itself has none.
|
|
291
|
+
return [{ kind: 'tokenSections', title: '', sections: def.tokenSections, columns: def.tokenColumns ?? 1 }];
|
|
292
|
+
}
|
|
283
293
|
if (def.tokenGallery) {
|
|
284
294
|
const title = def.fullWidthLabel ?? 'Tokens';
|
|
285
295
|
return [def.render ? { kind: 'preview', title, widths: 'full' } : { kind: 'empty', title, message: 'Nothing to preview.' }];
|
|
@@ -303,7 +313,13 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
|
|
|
303
313
|
blocks.push({ kind: 'grouped', title: 'Variant × configuration', size: def.specimenSize ?? 'regular', groups: grouped.groups });
|
|
304
314
|
states = grouped.leftovers;
|
|
305
315
|
} else if (def.variants) {
|
|
306
|
-
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
|
+
});
|
|
307
323
|
} else if (def.render) {
|
|
308
324
|
blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths) });
|
|
309
325
|
}
|
|
@@ -316,6 +332,7 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
|
|
|
316
332
|
title: secondary ? 'Other configurations' : 'States / configurations',
|
|
317
333
|
size: slotSize(def, states),
|
|
318
334
|
items: slotItems(states),
|
|
335
|
+
...(states.maxColumns !== undefined ? { maxColumns: states.maxColumns } : {}),
|
|
319
336
|
});
|
|
320
337
|
}
|
|
321
338
|
|
|
@@ -331,9 +348,11 @@ export interface PlacementBlock {
|
|
|
331
348
|
width?: number;
|
|
332
349
|
/** Measured height of the whole block, label included, while stacked. */
|
|
333
350
|
height: number;
|
|
334
|
-
/** 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. */
|
|
335
353
|
itemCount?: number;
|
|
336
354
|
size?: SpecimenSize;
|
|
355
|
+
maxColumns?: 1 | 2 | 3 | 4 | 5;
|
|
337
356
|
}
|
|
338
357
|
|
|
339
358
|
/** Side by side or stacked, for a page with exactly two blocks. Tries both and keeps the shorter;
|
|
@@ -346,8 +365,8 @@ export function choosePlacement({ available, gap, first, second }: { available:
|
|
|
346
365
|
const room = available - firstWidth - gap;
|
|
347
366
|
const minCell = second.size === 'wide' ? MATRIX_LAYOUT.columnMaxWidth : COLUMN_MIN_WIDTH[second.size];
|
|
348
367
|
if (room < minCell + 2) return 'stacked';
|
|
349
|
-
const stackedRows = listGeometry(second.itemCount, available, second.size).rows;
|
|
350
|
-
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;
|
|
351
370
|
const rowHeight = second.height / Math.max(1, stackedRows);
|
|
352
371
|
const sideHeight = Math.max(first.height, sideRows * rowHeight);
|
|
353
372
|
const stackedHeight = first.height + gap + second.height;
|
package/native/catalog/index.ts
CHANGED
|
@@ -24,6 +24,10 @@ 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';
|
|
30
|
+
export { TokenSections, TokenGrid, TokenTile } from './TokenLayouts';
|
|
27
31
|
export { DividedStack } from './DividedStack';
|
|
28
32
|
export { Swatch } from './Swatch';
|
|
29
33
|
export { PhoneFrame } from './PhoneFrame';
|
|
@@ -33,7 +37,10 @@ export { buildComponentManifest } from './manifest';
|
|
|
33
37
|
export type { ComponentManifestEntry, ManifestExample } from './manifest';
|
|
34
38
|
export { defineCatalogPage, buildCatalogSections } from './pageApi';
|
|
35
39
|
export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRecord } from './pageApi';
|
|
36
|
-
export type {
|
|
40
|
+
export type {
|
|
41
|
+
PropDef, SectionDef, NavGroup, SpecimenSize, SpecimenSurfaceKind, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis,
|
|
42
|
+
PreviewWidths, TokenSection, ComposedOfEntry,
|
|
43
|
+
} from './types';
|
|
37
44
|
export { UpdatePanel } from './UpdatePanel';
|
|
38
45
|
export { UPDATE_PAGE_ID } from './catalogNavigation';
|
|
39
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, 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. */
|
|
@@ -9,6 +9,14 @@ export interface CatalogPageInput<TId extends string = string> {
|
|
|
9
9
|
* default when writing the generated page index, so an authored page never has to repeat a name
|
|
10
10
|
* already implied by its file. */
|
|
11
11
|
id?: TId;
|
|
12
|
+
/** Display name; see `SectionDef.title`. */
|
|
13
|
+
title?: string;
|
|
14
|
+
/** Project-relative file shown as the page's Source when the page documents something other
|
|
15
|
+
* than a component, e.g. a token page pointing at `src/design-system/tokens.ts`. A component's
|
|
16
|
+
* own file always wins. */
|
|
17
|
+
source?: string;
|
|
18
|
+
/** See `SectionDef.osComponent`. */
|
|
19
|
+
osComponent?: 'full' | 'partial';
|
|
12
20
|
description: string;
|
|
13
21
|
whenToUse?: string;
|
|
14
22
|
a11y?: string;
|
|
@@ -16,11 +24,15 @@ export interface CatalogPageInput<TId extends string = string> {
|
|
|
16
24
|
states?: VariantSlot;
|
|
17
25
|
render?: () => React.ReactNode;
|
|
18
26
|
tokenGallery?: boolean;
|
|
27
|
+
tokenSections?: TokenSection[];
|
|
28
|
+
tokenColumns?: 1 | 2 | 3;
|
|
19
29
|
fullWidthLabel?: string;
|
|
20
30
|
comparison?: ComparisonDef;
|
|
21
31
|
specimenSize?: SpecimenSize;
|
|
32
|
+
specimenSurface?: SpecimenSurfaceKind;
|
|
22
33
|
previewWidths?: PreviewWidths;
|
|
23
34
|
hide?: SectionDef['hide'];
|
|
35
|
+
composedOf?: ComposedOfEntry[];
|
|
24
36
|
/** Export name this page documents; defaults to `id` for component pages. */
|
|
25
37
|
component?: string;
|
|
26
38
|
/** Sidebar group label. */
|
|
@@ -77,6 +89,17 @@ function mergePropNotes(component: GeneratedComponent, propNotes: Record<string,
|
|
|
77
89
|
return [...own, ...inherited];
|
|
78
90
|
}
|
|
79
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
|
+
|
|
80
103
|
function orderGroups<TId extends string>(groupIds: Map<string, TId[]>, groupOrder: string[] | undefined): NavGroup<TId>[] {
|
|
81
104
|
const listed = groupOrder ?? [];
|
|
82
105
|
const labels = [...groupIds.keys()];
|
|
@@ -102,6 +125,10 @@ export function buildCatalogSections<TId extends string>(
|
|
|
102
125
|
const documented = new Set(pages.map((page) => page.component ?? page.id));
|
|
103
126
|
const groupIds = new Map<string, TId[]>();
|
|
104
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)]);
|
|
105
132
|
|
|
106
133
|
// Design §4 "duplicate-page-id" rule's viewer-side equivalent — this is the one place in the
|
|
107
134
|
// whole viewer that already resolves every page's id to build sections/groups, so it costs
|
|
@@ -112,6 +139,7 @@ export function buildCatalogSections<TId extends string>(
|
|
|
112
139
|
if ((globalThis as { __DEV__?: boolean }).__DEV__) {
|
|
113
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".`);
|
|
114
141
|
seenIds.add(page.id);
|
|
142
|
+
for (const issue of validateComposedOf(page.composedOf, knownComposedOfNames)) console.warn(`[Catalog] ${page.id}: ${issue}`);
|
|
115
143
|
}
|
|
116
144
|
const componentName = page.component ?? page.id;
|
|
117
145
|
const component = componentByName.get(componentName);
|
|
@@ -121,20 +149,27 @@ export function buildCatalogSections<TId extends string>(
|
|
|
121
149
|
// to the page's own file as its Source instead of an empty path.
|
|
122
150
|
return {
|
|
123
151
|
id: page.id,
|
|
152
|
+
title: page.title,
|
|
153
|
+
osComponent: page.osComponent,
|
|
124
154
|
description: page.description,
|
|
125
|
-
path: component?.file ?? page.file,
|
|
155
|
+
path: component?.file ?? page.source ?? page.file,
|
|
126
156
|
whenToUse: page.whenToUse,
|
|
127
157
|
props: component ? mergePropNotes(component, page.propNotes) : undefined,
|
|
128
158
|
a11y: page.a11y,
|
|
129
159
|
variants: page.variants,
|
|
130
160
|
states: page.states,
|
|
131
161
|
render: page.render,
|
|
132
|
-
|
|
162
|
+
// `tokenSections` makes a page a token gallery by itself.
|
|
163
|
+
tokenGallery: page.tokenGallery || (page.tokenSections !== undefined && page.tokenSections.length > 0) || undefined,
|
|
164
|
+
tokenSections: page.tokenSections,
|
|
165
|
+
tokenColumns: page.tokenColumns,
|
|
133
166
|
fullWidthLabel: page.fullWidthLabel,
|
|
134
167
|
comparison: page.comparison,
|
|
135
168
|
specimenSize: page.specimenSize,
|
|
169
|
+
specimenSurface: page.specimenSurface,
|
|
136
170
|
previewWidths: page.previewWidths,
|
|
137
171
|
hide: component ? page.hide : { ...page.hide, props: true },
|
|
172
|
+
composedOf: page.composedOf,
|
|
138
173
|
};
|
|
139
174
|
});
|
|
140
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;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure layout maths for token pages — no React or React Native runtime imports, so it is unit
|
|
3
|
+
* tested directly (`__tests__/tokenLayout.test.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Token pages lay out in two levels:
|
|
6
|
+
* - sections (one titled card per kind of token, e.g. "Semantic" vs "Palette") flow in up to
|
|
7
|
+
* `tokenColumns` columns, dropping to fewer rather than squeezing a section under its minimum;
|
|
8
|
+
* - inside a section, `TokenGrid` tiles as many equal tiles per row as keep their minimum width.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Narrowest a side-by-side token section may get before the page drops a column. */
|
|
12
|
+
export const TOKEN_SECTION_MIN_WIDTH = 280;
|
|
13
|
+
/** Gap between side-by-side sections and between tiles. */
|
|
14
|
+
export const TOKEN_LAYOUT_GAP = 20;
|
|
15
|
+
|
|
16
|
+
/** How many section columns fit in `available` px, at most `requested`, never below 1. */
|
|
17
|
+
export function sectionColumns(available: number, requested: number): number {
|
|
18
|
+
const fit = Math.floor((available + TOKEN_LAYOUT_GAP) / (TOKEN_SECTION_MIN_WIDTH + TOKEN_LAYOUT_GAP));
|
|
19
|
+
return Math.max(1, Math.min(requested, fit));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Equal tiles per row for a `TokenGrid`: as many as keep `minTileWidth` (capped by `maxColumns`),
|
|
23
|
+
* sized so the row is filled exactly. */
|
|
24
|
+
export function tileGeometry(available: number, minTileWidth: number, maxColumns = Infinity): { columns: number; tileWidth: number } {
|
|
25
|
+
const fit = Math.floor((available + TOKEN_LAYOUT_GAP) / (minTileWidth + TOKEN_LAYOUT_GAP));
|
|
26
|
+
const columns = Math.max(1, Math.min(maxColumns, fit));
|
|
27
|
+
const tileWidth = columns === 1 ? available : (available - TOKEN_LAYOUT_GAP * (columns - 1)) / columns;
|
|
28
|
+
return { columns, tileWidth };
|
|
29
|
+
}
|
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;
|
|
@@ -102,6 +130,13 @@ export interface ComparisonDef {
|
|
|
102
130
|
* typed to the app's real section list without this file needing to know what they are. */
|
|
103
131
|
export interface SectionDef<TId extends string = string> {
|
|
104
132
|
id: TId;
|
|
133
|
+
/** Display name for the heading, sidebar and search (e.g. "Control heights"). Defaults to `id`,
|
|
134
|
+
* which stays the page's address (`#ControlHeights`) and its name in `explain`. */
|
|
135
|
+
title?: string;
|
|
136
|
+
/** Marks a component that is, or contains, a native platform control rather than a custom one:
|
|
137
|
+
* `'full'` shows an "OS component" badge under the title, `'partial'` "Partly OS component".
|
|
138
|
+
* Say in `description` which part is native and how it differs on web. */
|
|
139
|
+
osComponent?: 'full' | 'partial';
|
|
105
140
|
description: string;
|
|
106
141
|
path: string;
|
|
107
142
|
/** One sentence disambiguating this component from its closest look-alike(s) — the deciding
|
|
@@ -163,6 +198,14 @@ export interface SectionDef<TId extends string = string> {
|
|
|
163
198
|
* configurations list, and shows only a Quick reference card with the source path (there's no
|
|
164
199
|
* component behavior to document). */
|
|
165
200
|
tokenGallery?: boolean;
|
|
201
|
+
/** Token pages with more than one kind of token: one titled card per section (e.g. "Semantic"
|
|
202
|
+
* and "Palette", or "Body" and "Title"), instead of a single "Tokens" card. When set, `render`
|
|
203
|
+
* is not used and the page counts as a token gallery. See `TokenSection`. */
|
|
204
|
+
tokenSections?: TokenSection[];
|
|
205
|
+
/** How many sections sit side by side (1–3, default 1). Narrow sections such as type families
|
|
206
|
+
* use 2–3; the page drops columns rather than squeeze a section under 280px. A section with
|
|
207
|
+
* `wide: true` always takes a full row. */
|
|
208
|
+
tokenColumns?: 1 | 2 | 3;
|
|
166
209
|
/** Overrides the `tokenGallery` block's label (default `'Tokens'`) — e.g. `'Preview'` for a page
|
|
167
210
|
* that's a composed, realistic usage example rather than a list of raw token values. Ignored
|
|
168
211
|
* unless `tokenGallery` is also set. */
|
|
@@ -174,6 +217,10 @@ export interface SectionDef<TId extends string = string> {
|
|
|
174
217
|
/** Width class for this page's specimens. Without it, full-width (`itemsFill`) slots are 'wide'
|
|
175
218
|
* and everything else is 'regular'. */
|
|
176
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;
|
|
177
224
|
/** Widths for this page's `render()` preview, e.g. `[402, 320]` to add a small-phone example.
|
|
178
225
|
* Defaults to the catalog's default (CatalogShell `defaultPreviewWidths`), else `[402]`. */
|
|
179
226
|
previewWidths?: PreviewWidths;
|
|
@@ -188,6 +235,10 @@ export interface SectionDef<TId extends string = string> {
|
|
|
188
235
|
props?: boolean;
|
|
189
236
|
accessibility?: boolean;
|
|
190
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[];
|
|
191
242
|
}
|
|
192
243
|
|
|
193
244
|
/** A labeled group of section ids in the sidebar (e.g. "Components" vs "Tokens"). */
|
|
@@ -206,3 +257,14 @@ export interface UpdateNotice {
|
|
|
206
257
|
summary: string[];
|
|
207
258
|
releasedAt?: string;
|
|
208
259
|
}
|
|
260
|
+
|
|
261
|
+
/** One titled card on a token page (`SectionDef.tokenSections`). */
|
|
262
|
+
export interface TokenSection {
|
|
263
|
+
/** The kind of token this card holds, e.g. "Semantic colours", "Palette", "Body". */
|
|
264
|
+
title: string;
|
|
265
|
+
/** One line under the title: what this kind of token is for. */
|
|
266
|
+
desc?: string;
|
|
267
|
+
/** Take a full row even when the page sets `tokenColumns` (e.g. palette ramps). */
|
|
268
|
+
wide?: boolean;
|
|
269
|
+
render: () => React.ReactNode;
|
|
270
|
+
}
|
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>
|