@krapwoo/ds-viewer 0.4.2 → 0.4.3
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 +52 -1
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/explain.js +16 -0
- package/dist/cli/props.js +20 -5
- package/dist/cli/staticPage.js +22 -0
- package/dist/cli/sync.js +2 -1
- package/dist/native/catalog/catalogNavigation.js +4 -2
- package/dist/native/catalog/comparison.js +4 -0
- 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/SectionBlock.tsx +29 -6
- package/native/catalog/TokenLayouts.tsx +92 -0
- package/native/catalog/TokenRow.tsx +26 -2
- package/native/catalog/catalogNavigation.ts +7 -2
- package/native/catalog/comparison.ts +6 -1
- package/native/catalog/index.ts +2 -1
- package/native/catalog/pageApi.ts +18 -3
- package/native/catalog/tokenLayout.ts +29 -0
- package/native/catalog/types.ts +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -204,7 +204,58 @@ renders in a Preview card (phone width for components). For a token-gallery sect
|
|
|
204
204
|
data, not a component — see `ColorsGallery`/`SpacingGallery`/`TypographyGallery`), set
|
|
205
205
|
`tokenGallery: true` instead of `props`/`a11y`/`states` — `SectionBlock` then renders `render()`'s
|
|
206
206
|
output full width under a "Tokens" label and shows only a Quick reference card with the source
|
|
207
|
-
path.
|
|
207
|
+
path. For anything beyond a single list of tokens, use the token-page layouts below.
|
|
208
|
+
|
|
209
|
+
### Organising the catalog
|
|
210
|
+
|
|
211
|
+
- **Two sidebar groups: `Tokens`, then `Components`** (`groupOrder: ['Tokens', 'Components']`).
|
|
212
|
+
Pages sort A–Z inside each group. Category groups ("Actions", "Status") make readers guess
|
|
213
|
+
where a component lives; search and A–Z already find it.
|
|
214
|
+
- **Wide components at 3 columns.** Give block-level components (Banner, Card, Toast,
|
|
215
|
+
InputField) `specimenSize: 'regular'`, so Variants and States sit 3 across on a laptop
|
|
216
|
+
(about 316px each). `'wide'` (2 columns, phone width) is only for pages where the full phone
|
|
217
|
+
width is what you're checking, such as truncation or full-screen sheets. 4 columns is too narrow
|
|
218
|
+
for these components.
|
|
219
|
+
- **Mark OS components.** When a component is, or contains, a native platform control rather than
|
|
220
|
+
a custom one (a native date picker, iOS Liquid Glass), set `osComponent: 'full'` or
|
|
221
|
+
`'partial'`. The page shows an "OS component" or "Partly OS component" badge under its title;
|
|
222
|
+
say in `description` which part is native and how it differs on web, where the native part
|
|
223
|
+
usually can't render. Custom replacements styled after a platform control (a custom switch or
|
|
224
|
+
spinner) are not OS components.
|
|
225
|
+
- **Readable titles.** The page id is its address (`#ControlHeights`) and comes from the file
|
|
226
|
+
name; set `title: 'Control heights'` for the heading, sidebar and search. Pages still sort by id.
|
|
227
|
+
- **Point token pages at their token file.** A token page has no component, so its Source would be
|
|
228
|
+
the page file; set `source: 'src/design-system/tokens.ts'` (project-relative) instead.
|
|
229
|
+
- **Coverage only counts the component's own options.** `doctor`'s `option-not-covered` checks
|
|
230
|
+
string-literal unions declared in the component's own folder or a token folder. A union
|
|
231
|
+
declared in another component's folder, such as a shared `IconName`, is that component's
|
|
232
|
+
options, so a Banner page never needs one example per icon.
|
|
233
|
+
|
|
234
|
+
### Token pages
|
|
235
|
+
|
|
236
|
+
A token page shows each token at its real value with its role. Pick the layout by the token's
|
|
237
|
+
shape:
|
|
238
|
+
|
|
239
|
+
| Token shape | Layout | Example |
|
|
240
|
+
|---|---|---|
|
|
241
|
+
| Small, self-contained preview (radius, shadow, control height, a single colour) | `TokenGrid` of `TokenTile`s: equal tiles in as many columns as fit | Radius, Shadow, ControlHeights |
|
|
242
|
+
| Narrow sample with a long note (icon size, spacing) | `TokenRow` with `notePlacement="right"`: one line per token | IconSizes |
|
|
243
|
+
| Several kinds of token on one page | `tokenSections`: one titled card per kind | Colors (semantic vs palette) |
|
|
244
|
+
| A scale with families (type) | `tokenSections` with `tokenColumns: 2` or `3`, one section per family | Typography (label, body, emphasis…) |
|
|
245
|
+
|
|
246
|
+
- **`tokenSections: [{ title, desc?, wide?, render }]`** replaces `render`. Each section is its
|
|
247
|
+
own titled card. Keep different kinds of token in different sections, never mixed: semantic roles
|
|
248
|
+
apart from raw palette ramps, each type family apart.
|
|
249
|
+
- **`tokenColumns: 1 | 2 | 3`** places sections side by side. Use 2–3 for narrow sections (type
|
|
250
|
+
families); the page drops a column rather than squeeze a section under 280px. `wide: true`
|
|
251
|
+
gives a section its own full row, for wide content such as palette ramps.
|
|
252
|
+
- **Show each token's role, not its category.** The note says where or why the token is used, in
|
|
253
|
+
that order of preference: the token file's own comments, then a usage scan of the code (name the
|
|
254
|
+
main components and how many files use it, and say so when a token is unused). Never invent
|
|
255
|
+
advice, and never write a generic category such as "Regular reading text" when the name and
|
|
256
|
+
value already say it. If there's nothing grounded to say, leave the note out.
|
|
257
|
+
- **Label each sample with its name and real value** (`bodyMd · 16/22 · 400`, `medium · 12px`).
|
|
258
|
+
Use realistic sample text from the product, short enough to fit its column at the largest size.
|
|
208
259
|
|
|
209
260
|
## Keeping the catalog current
|
|
210
261
|
|
package/dist/cli/doctor.js
CHANGED
|
@@ -55,7 +55,7 @@ function checkComponentExportRemoved(pages, components) {
|
|
|
55
55
|
* `description` — in which case the viewer shows the exact same "No examples documented." an
|
|
56
56
|
* undocumented component would. */
|
|
57
57
|
function pageHasExamples(page) {
|
|
58
|
-
return Boolean(page.comparison) || Boolean(page.variantsItems?.length) || Boolean(page.statesItems?.length) || page.hasRender === true;
|
|
58
|
+
return Boolean(page.comparison) || Boolean(page.variantsItems?.length) || Boolean(page.statesItems?.length) || page.hasRender === true || Boolean(page.tokenSectionTitles?.length);
|
|
59
59
|
}
|
|
60
60
|
function checkComponentNoExamples(pages, components) {
|
|
61
61
|
const byComponent = new Map();
|
package/dist/cli/explain.js
CHANGED
|
@@ -4,6 +4,7 @@ import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
|
|
|
4
4
|
import { resolveGlob } from "./glob.js";
|
|
5
5
|
import { axisItems, choosePlacement, gridColumnLimit, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
|
|
6
6
|
import { CATALOG_LAYOUT } from "../native/catalog/tokens.js";
|
|
7
|
+
import { sectionColumns } from "../native/catalog/tokenLayout.js";
|
|
7
8
|
function placeholderItem(item) {
|
|
8
9
|
return { key: item.key, name: item.name, node: null, group: item.group };
|
|
9
10
|
}
|
|
@@ -35,6 +36,8 @@ function toSectionDef(page) {
|
|
|
35
36
|
states: page.statesItems ? { itemsFill: page.statesItemsFill, items: page.statesItems.map(placeholderItem) } : undefined,
|
|
36
37
|
comparison,
|
|
37
38
|
render: page.hasRender ? () => null : undefined,
|
|
39
|
+
tokenSections: page.tokenSectionTitles?.map((title, i) => ({ title, wide: page.tokenSectionWide?.[i] || undefined, render: () => null })),
|
|
40
|
+
tokenColumns: page.tokenColumns === 2 || page.tokenColumns === 3 ? page.tokenColumns : undefined,
|
|
38
41
|
hide: (page.hideVariants || page.hideStates) ? { variants: page.hideVariants, states: page.hideStates } : undefined,
|
|
39
42
|
};
|
|
40
43
|
}
|
|
@@ -64,6 +67,19 @@ function explainBlock(block) {
|
|
|
64
67
|
const widths = block.widths === 'full' ? 'full width' : `${block.widths.join('/')}px`;
|
|
65
68
|
return { kind: 'preview', title: block.title, reason: `render() content at ${widths}.` };
|
|
66
69
|
}
|
|
70
|
+
if (block.kind === 'tokenSections') {
|
|
71
|
+
const n = block.sections.length;
|
|
72
|
+
const titles = block.sections.every((s) => s.title) ? ` (${block.sections.map((s) => s.title).join(', ')})` : '';
|
|
73
|
+
const wide = block.sections.filter((s) => s.wide).length;
|
|
74
|
+
const narrow = n - wide;
|
|
75
|
+
const fit = sectionColumns(MATRIX_LAYOUT.laptopContentWidth, block.columns);
|
|
76
|
+
const parts = [];
|
|
77
|
+
if (narrow > 0)
|
|
78
|
+
parts.push(narrow > 1 && fit > 1 ? `${Math.min(fit, narrow)} side by side on a 1280px laptop` : 'one per row');
|
|
79
|
+
if (wide > 0)
|
|
80
|
+
parts.push(`${wide} full width`);
|
|
81
|
+
return { kind: 'tokenSections', title: 'Token sections', reason: `${n} section${n === 1 ? '' : 's'}${titles}: ${parts.join('; ')}.` };
|
|
82
|
+
}
|
|
67
83
|
return { kind: 'empty', title: block.title, reason: block.message };
|
|
68
84
|
}
|
|
69
85
|
/** Mirrors `native/catalog/SectionBlock.tsx`'s own `blockWidth` helper: a list block's width is its
|
package/dist/cli/props.js
CHANGED
|
@@ -97,7 +97,7 @@ export function createComponentReader(entryFiles, options = {}) {
|
|
|
97
97
|
const type = checker.getTypeOfSymbolAtLocation(resolved, declaration);
|
|
98
98
|
if (!isComponentType(checker, type))
|
|
99
99
|
continue;
|
|
100
|
-
records.push(readComponentRecord(checker, exportSymbol.getName(), type, declaration, options.optionRoots ?? []));
|
|
100
|
+
records.push(readComponentRecord(checker, exportSymbol.getName(), type, declaration, options.optionRoots ?? [], options.componentRoots ?? []));
|
|
101
101
|
}
|
|
102
102
|
return records.sort((a, b) => a.name.localeCompare(b.name));
|
|
103
103
|
};
|
|
@@ -151,7 +151,7 @@ function isComponentType(checker, type) {
|
|
|
151
151
|
return true;
|
|
152
152
|
return COMPONENT_TYPE_HINT.test(checker.typeToString(type));
|
|
153
153
|
}
|
|
154
|
-
function readComponentRecord(checker, name, type, declaration, optionRoots) {
|
|
154
|
+
function readComponentRecord(checker, name, type, declaration, optionRoots, componentRoots = []) {
|
|
155
155
|
const sourceFile = declaration.getSourceFile();
|
|
156
156
|
const signature = type.getCallSignatures()[0];
|
|
157
157
|
const propsType = signature && signature.parameters.length > 0 ? checker.getTypeOfSymbolAtLocation(signature.parameters[0], declaration) : undefined;
|
|
@@ -180,7 +180,7 @@ function readComponentRecord(checker, name, type, declaration, optionRoots) {
|
|
|
180
180
|
required: !isPartial && merged.requiredInEvery,
|
|
181
181
|
default: implementationDefaults.get(prop.getName()) ?? (jsDocDefault ? ts.displayPartsToString(jsDocDefault.text) : undefined),
|
|
182
182
|
desc: isPartial ? `${baseDesc} Only with some variants of this prop's type.`.trim() : baseDesc,
|
|
183
|
-
options: readOptions(checker, propType, propFile, optionRoots),
|
|
183
|
+
options: readOptions(checker, propType, propFile, optionRoots, componentRoots),
|
|
184
184
|
});
|
|
185
185
|
}
|
|
186
186
|
}
|
|
@@ -383,9 +383,10 @@ function mergeUnionProps(checker, branches) {
|
|
|
383
383
|
* 2 or more string literals, AND that union is declared inside one of `optionRoots` — checked
|
|
384
384
|
* against the union's alias symbol's declaration file (a named type like `Variant`), or the prop's
|
|
385
385
|
* own declaration file for an inline union with no alias. A single string literal is never
|
|
386
|
-
* options; an out-of-root union (e.g. a 44-member `IconName` from `icons/`) is skipped
|
|
386
|
+
* options; an out-of-root union (e.g. a 44-member `IconName` from `icons/`) is skipped, and so is a
|
|
387
|
+
* union declared in a different component's folder under a component root (a sibling `Icon/`).
|
|
387
388
|
*/
|
|
388
|
-
function readOptions(checker, propType, propDeclarationFile, optionRoots) {
|
|
389
|
+
function readOptions(checker, propType, propDeclarationFile, optionRoots, componentRoots = []) {
|
|
389
390
|
if (optionRoots.length === 0)
|
|
390
391
|
return undefined;
|
|
391
392
|
const nonNullable = checker.getNonNullableType(propType);
|
|
@@ -397,8 +398,22 @@ function readOptions(checker, propType, propDeclarationFile, optionRoots) {
|
|
|
397
398
|
const isLocal = optionRoots.some((root) => declarationFile === root || declarationFile.startsWith(`${root}${path.sep}`));
|
|
398
399
|
if (!isLocal)
|
|
399
400
|
return undefined;
|
|
401
|
+
if (declarationFile !== propDeclarationFile && isInOtherComponent(declarationFile, propDeclarationFile, componentRoots))
|
|
402
|
+
return undefined;
|
|
400
403
|
return nonNullable.types.map((t) => t.value);
|
|
401
404
|
}
|
|
405
|
+
/** True when `file` sits in a different component subfolder than `ownFile` under the same root. */
|
|
406
|
+
function isInOtherComponent(file, ownFile, componentRoots) {
|
|
407
|
+
const componentOf = (target, root) => {
|
|
408
|
+
const rel = path.relative(root, target);
|
|
409
|
+
return rel.startsWith('..') || path.isAbsolute(rel) ? undefined : rel.split(path.sep)[0];
|
|
410
|
+
};
|
|
411
|
+
return componentRoots.some((root) => {
|
|
412
|
+
const theirs = componentOf(file, root);
|
|
413
|
+
const ours = componentOf(ownFile, root);
|
|
414
|
+
return theirs !== undefined && ours !== undefined && theirs !== ours && path.extname(theirs) === '';
|
|
415
|
+
});
|
|
416
|
+
}
|
|
402
417
|
/** Finds the real implementation function behind a `React.memo(Impl)` / `forwardRef(function
|
|
403
418
|
* Name(...) {})` wrapper and reads each destructured parameter's default initializer as source
|
|
404
419
|
* text (design §3: "shown as source text"). Resolving the *value* of a non-literal default
|
package/dist/cli/staticPage.js
CHANGED
|
@@ -71,6 +71,28 @@ function readStaticPage(file) {
|
|
|
71
71
|
page.specimenSize = stringLiteral(resolve(specimenSizeProp.initializer, consts, new Set()));
|
|
72
72
|
if (tokenGalleryProp)
|
|
73
73
|
page.tokenGallery = resolve(tokenGalleryProp.initializer, consts, new Set()).kind === ts.SyntaxKind.TrueKeyword;
|
|
74
|
+
const tokenSectionsProp = findProp(pageObject, 'tokenSections');
|
|
75
|
+
if (tokenSectionsProp) {
|
|
76
|
+
const list = resolve(tokenSectionsProp.initializer, consts, new Set());
|
|
77
|
+
const items = ts.isArrayLiteralExpression(list) ? list.elements.map((element) => resolve(element, consts, new Set())) : [list];
|
|
78
|
+
const field = (item, name) => (ts.isObjectLiteralExpression(item) ? findProp(item, name) : undefined);
|
|
79
|
+
page.tokenSectionTitles = items.map((item) => {
|
|
80
|
+
const title = field(item, 'title');
|
|
81
|
+
return (title && stringLiteral(resolve(title.initializer, consts, new Set()))) ?? '';
|
|
82
|
+
});
|
|
83
|
+
page.tokenSectionWide = items.map((item) => {
|
|
84
|
+
const wide = field(item, 'wide');
|
|
85
|
+
return wide ? resolve(wide.initializer, consts, new Set()).kind === ts.SyntaxKind.TrueKeyword : false;
|
|
86
|
+
});
|
|
87
|
+
if (page.tokenSectionTitles.length > 0)
|
|
88
|
+
page.tokenGallery = true;
|
|
89
|
+
}
|
|
90
|
+
const tokenColumnsProp = findProp(pageObject, 'tokenColumns');
|
|
91
|
+
if (tokenColumnsProp) {
|
|
92
|
+
const value = resolve(tokenColumnsProp.initializer, consts, new Set());
|
|
93
|
+
if (ts.isNumericLiteral(value))
|
|
94
|
+
page.tokenColumns = Number(value.text);
|
|
95
|
+
}
|
|
74
96
|
page.hasWhenToUse = findProp(pageObject, 'whenToUse') !== undefined;
|
|
75
97
|
page.hasA11y = findProp(pageObject, 'a11y') !== undefined;
|
|
76
98
|
page.hasRender = findProp(pageObject, 'render') !== undefined;
|
package/dist/cli/sync.js
CHANGED
|
@@ -49,7 +49,8 @@ export function sync(config, resolveOptions) {
|
|
|
49
49
|
const fallbackPaths = hasEntryOutsideProjectRoot
|
|
50
50
|
? { '*': [path.join(config.projectRoot, 'node_modules', '@types', '*'), path.join(config.projectRoot, 'node_modules', '*')] }
|
|
51
51
|
: undefined;
|
|
52
|
-
const
|
|
52
|
+
const componentRoots = [...new Set(config.components.map((pattern) => path.join(config.projectRoot, globBaseFolder(pattern))))];
|
|
53
|
+
const readComponent = createComponentReader(componentEntryFiles, { ...resolveOptions, optionRoots, componentRoots, fallbackPaths });
|
|
53
54
|
const warnings = [];
|
|
54
55
|
for (const entryFile of componentEntryFiles) {
|
|
55
56
|
try {
|
|
@@ -34,12 +34,14 @@ export function neighbors(order, id) {
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
/** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed. */
|
|
37
|
-
export function filterGroups(groups, query
|
|
37
|
+
export function filterGroups(groups, query,
|
|
38
|
+
/** Display titles by id; a page matches on its id or its title. */
|
|
39
|
+
labels) {
|
|
38
40
|
const q = query.trim().toLowerCase();
|
|
39
41
|
return groups
|
|
40
42
|
.map((group) => ({
|
|
41
43
|
label: group.label,
|
|
42
|
-
ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q)) : group.ids),
|
|
44
|
+
ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q) || (labels?.get(id)?.toLowerCase().includes(q) ?? false)) : group.ids),
|
|
43
45
|
}))
|
|
44
46
|
.filter((group) => group.ids.length > 0);
|
|
45
47
|
}
|
|
@@ -213,6 +213,10 @@ function groupStates(variants, states) {
|
|
|
213
213
|
* never shows an empty block next to a real one; with nothing documented at all it shows one
|
|
214
214
|
* "No examples documented." block. */
|
|
215
215
|
export function presentationBlocks(def, options = {}) {
|
|
216
|
+
if (def.tokenSections && def.tokenSections.length > 0) {
|
|
217
|
+
// Each section carries its own label, so the block itself has none.
|
|
218
|
+
return [{ kind: 'tokenSections', title: '', sections: def.tokenSections, columns: def.tokenColumns ?? 1 }];
|
|
219
|
+
}
|
|
216
220
|
if (def.tokenGallery) {
|
|
217
221
|
const title = def.fullWidthLabel ?? 'Tokens';
|
|
218
222
|
return [def.render ? { kind: 'preview', title, widths: 'full' } : { kind: 'empty', title, message: 'Nothing to preview.' }];
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
/** Narrowest a side-by-side token section may get before the page drops a column. */
|
|
11
|
+
export const TOKEN_SECTION_MIN_WIDTH = 280;
|
|
12
|
+
/** Gap between side-by-side sections and between tiles. */
|
|
13
|
+
export const TOKEN_LAYOUT_GAP = 20;
|
|
14
|
+
/** How many section columns fit in `available` px, at most `requested`, never below 1. */
|
|
15
|
+
export function sectionColumns(available, requested) {
|
|
16
|
+
const fit = Math.floor((available + TOKEN_LAYOUT_GAP) / (TOKEN_SECTION_MIN_WIDTH + TOKEN_LAYOUT_GAP));
|
|
17
|
+
return Math.max(1, Math.min(requested, fit));
|
|
18
|
+
}
|
|
19
|
+
/** Equal tiles per row for a `TokenGrid`: as many as keep `minTileWidth` (capped by `maxColumns`),
|
|
20
|
+
* sized so the row is filled exactly. */
|
|
21
|
+
export function tileGeometry(available, minTileWidth, maxColumns = Infinity) {
|
|
22
|
+
const fit = Math.floor((available + TOKEN_LAYOUT_GAP) / (minTileWidth + TOKEN_LAYOUT_GAP));
|
|
23
|
+
const columns = Math.max(1, Math.min(maxColumns, fit));
|
|
24
|
+
const tileWidth = columns === 1 ? available : (available - TOKEN_LAYOUT_GAP * (columns - 1)) / columns;
|
|
25
|
+
return { columns, tileWidth };
|
|
26
|
+
}
|
|
@@ -136,6 +136,7 @@ export function CatalogShell<TId extends string>({
|
|
|
136
136
|
updateEndpoint?: { baseUrl: string; secret: string };
|
|
137
137
|
}) {
|
|
138
138
|
const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
|
|
139
|
+
const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
|
|
139
140
|
const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys())), [groups, sectionsById]);
|
|
140
141
|
const [active, setActive] = useState<ShellPageId<TId> | undefined>(() =>
|
|
141
142
|
isWeb() ? resolveActiveFromHash(window.location.hash, order) : order[0],
|
|
@@ -215,7 +216,7 @@ export function CatalogShell<TId extends string>({
|
|
|
215
216
|
|
|
216
217
|
return (
|
|
217
218
|
<View style={styles.root}>
|
|
218
|
-
<CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} active={active} onPress={select} footer={footer} />
|
|
219
|
+
<CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} />
|
|
219
220
|
<ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
|
|
220
221
|
{active === UPDATE_PAGE_ID ? (
|
|
221
222
|
<UpdatePanel update={update ?? null} endpoint={updateEndpoint} appName={appName} headingRef={headingRef} />
|
|
@@ -233,6 +234,7 @@ export function CatalogShell<TId extends string>({
|
|
|
233
234
|
previousId: neighbors(order, activeDef.id).previous,
|
|
234
235
|
nextId: neighbors(order, activeDef.id).next,
|
|
235
236
|
onNavigate: select,
|
|
237
|
+
labelOf: (id) => labels.get(id) ?? id,
|
|
236
238
|
}}
|
|
237
239
|
headingRef={headingRef}
|
|
238
240
|
defaultPreviewWidths={defaultPreviewWidths}
|
|
@@ -13,7 +13,7 @@ function webLinkProps(id: string, active: boolean) {
|
|
|
13
13
|
|
|
14
14
|
/** One nav link. Hover via onHoverIn/onHoverOut (react-native-web fires them; native never does);
|
|
15
15
|
* keyboard focus draws the catalog focus ring because react-native-web removes the browser's. */
|
|
16
|
-
function NavItem<TId extends string>({ id, active, onPress }: { id: TId; active: boolean; onPress: () => void }) {
|
|
16
|
+
function NavItem<TId extends string>({ id, label, active, onPress }: { id: TId; label: string; active: boolean; onPress: () => void }) {
|
|
17
17
|
const [hovered, setHovered] = useState(false);
|
|
18
18
|
const [focused, setFocused] = useState(false);
|
|
19
19
|
return (
|
|
@@ -38,7 +38,7 @@ function NavItem<TId extends string>({ id, active, onPress }: { id: TId; active:
|
|
|
38
38
|
focused && styles.focusRing,
|
|
39
39
|
]}
|
|
40
40
|
>
|
|
41
|
-
<Text style={[styles.label, active && styles.labelActive]}>{
|
|
41
|
+
<Text style={[styles.label, active && styles.labelActive]}>{label}</Text>
|
|
42
42
|
</Pressable>
|
|
43
43
|
);
|
|
44
44
|
}
|
|
@@ -79,6 +79,7 @@ export function CatalogSidebar<TId extends string>({
|
|
|
79
79
|
logoImageSource,
|
|
80
80
|
caption,
|
|
81
81
|
groups,
|
|
82
|
+
labels,
|
|
82
83
|
active,
|
|
83
84
|
onPress,
|
|
84
85
|
footer,
|
|
@@ -91,6 +92,8 @@ export function CatalogSidebar<TId extends string>({
|
|
|
91
92
|
logoImageSource?: ImageSourcePropType;
|
|
92
93
|
caption: string;
|
|
93
94
|
groups: NavGroup<TId>[];
|
|
95
|
+
/** Display titles by page id (`SectionDef.title`); pages without one show their id. */
|
|
96
|
+
labels?: ReadonlyMap<string, string>;
|
|
94
97
|
active: string | undefined;
|
|
95
98
|
onPress: (id: TId) => void;
|
|
96
99
|
/** The quiet update-notice link below the nav list (design §5's footer line), or `undefined`
|
|
@@ -112,7 +115,7 @@ export function CatalogSidebar<TId extends string>({
|
|
|
112
115
|
const [loadedAspect, setLoadedAspect] = useState<number | undefined>(undefined);
|
|
113
116
|
const [logoFailed, setLogoFailed] = useState(false);
|
|
114
117
|
const [nameWrapped, setNameWrapped] = useState(false);
|
|
115
|
-
const filtered = filterGroups(groups, query);
|
|
118
|
+
const filtered = filterGroups(groups, query, labels);
|
|
116
119
|
|
|
117
120
|
const logoAspect = staticAspect ?? loadedAspect;
|
|
118
121
|
const attemptingLogo = Boolean(logoImageSource) && !logoFailed;
|
|
@@ -191,7 +194,7 @@ export function CatalogSidebar<TId extends string>({
|
|
|
191
194
|
<View key={group.label}>
|
|
192
195
|
<Text style={styles.groupLabel}>{group.label}</Text>
|
|
193
196
|
{group.ids.map((id) => (
|
|
194
|
-
<NavItem key={id} id={id} active={active === id} onPress={() => onPress(id)} />
|
|
197
|
+
<NavItem key={id} id={id} label={labels?.get(id) ?? id} active={active === id} onPress={() => onPress(id)} />
|
|
195
198
|
))}
|
|
196
199
|
</View>
|
|
197
200
|
))}
|
|
@@ -5,6 +5,7 @@ import { ComparisonGrid } from './ComparisonGrid';
|
|
|
5
5
|
import { ComparisonGroups } from './ComparisonGroups';
|
|
6
6
|
import { ComparisonList } from './ComparisonList';
|
|
7
7
|
import { ReferenceDetails } from './ReferenceDetails';
|
|
8
|
+
import { TokenSections } from './TokenLayouts';
|
|
8
9
|
import { choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
|
|
9
10
|
import type { PreviewWidths, SectionDef } from './types';
|
|
10
11
|
|
|
@@ -45,23 +46,27 @@ export interface SectionPager<TId extends string> {
|
|
|
45
46
|
previousId: TId | null;
|
|
46
47
|
nextId: TId | null;
|
|
47
48
|
onNavigate: (id: TId) => void;
|
|
49
|
+
/** Display title for a page id, for the buttons' accessible names. */
|
|
50
|
+
labelOf?: (id: TId) => string;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
function PagerButton<TId extends string>({
|
|
51
54
|
direction,
|
|
52
55
|
targetId,
|
|
53
56
|
onNavigate,
|
|
57
|
+
labelOf,
|
|
54
58
|
}: {
|
|
55
59
|
direction: 'previous' | 'next';
|
|
56
60
|
targetId: TId | null;
|
|
57
61
|
onNavigate: (id: TId) => void;
|
|
62
|
+
labelOf?: (id: TId) => string;
|
|
58
63
|
}) {
|
|
59
64
|
const [hovered, setHovered] = useState(false);
|
|
60
65
|
const [focused, setFocused] = useState(false);
|
|
61
66
|
const disabled = targetId == null;
|
|
62
67
|
const label = disabled
|
|
63
68
|
? direction === 'previous' ? 'No previous page' : 'No next page'
|
|
64
|
-
: `${direction === 'previous' ? 'Previous' : 'Next'}: ${targetId}`;
|
|
69
|
+
: `${direction === 'previous' ? 'Previous' : 'Next'}: ${labelOf ? labelOf(targetId) : targetId}`;
|
|
65
70
|
return (
|
|
66
71
|
<Pressable
|
|
67
72
|
onPress={() => {
|
|
@@ -127,6 +132,8 @@ function BlockContent<TId extends string>({ block, def }: { block: PresentationB
|
|
|
127
132
|
return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} />;
|
|
128
133
|
case 'preview':
|
|
129
134
|
return def.render ? <Preview render={def.render} widths={block.widths} /> : null;
|
|
135
|
+
case 'tokenSections':
|
|
136
|
+
return <TokenSections sections={block.sections} columns={block.columns} />;
|
|
130
137
|
default:
|
|
131
138
|
return (
|
|
132
139
|
<View style={styles.previewCard}>
|
|
@@ -191,11 +198,11 @@ function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock
|
|
|
191
198
|
<View onLayout={onContainerLayout} style={[styles.blocks, side && styles.blocksSide]}>
|
|
192
199
|
{blocks.map((block, i) => (
|
|
193
200
|
<View
|
|
194
|
-
key={block.title}
|
|
201
|
+
key={block.title || block.kind}
|
|
195
202
|
onLayout={onBlockLayout(i)}
|
|
196
203
|
style={side ? (i === 0 ? { width: blockWidth(block, available), flexShrink: 0 } : styles.blockFill) : undefined}
|
|
197
204
|
>
|
|
198
|
-
<Text style={styles.blockLabel}>{block.title}</Text>
|
|
205
|
+
{block.title ? <Text style={styles.blockLabel}>{block.title}</Text> : null}
|
|
199
206
|
<BlockContent block={block} def={def} />
|
|
200
207
|
</View>
|
|
201
208
|
))}
|
|
@@ -247,14 +254,19 @@ export function SectionBlock<TId extends string>({
|
|
|
247
254
|
<View style={styles.titleText}>
|
|
248
255
|
{/* The focus target is the heading itself, so assistive tech announces its role and level. */}
|
|
249
256
|
<View ref={headingRef} tabIndex={-1} role="heading" {...headingLevelProps} style={styles.headingTarget}>
|
|
250
|
-
<Text style={styles.title}>{def.id}</Text>
|
|
257
|
+
<Text style={styles.title}>{def.title ?? def.id}</Text>
|
|
251
258
|
</View>
|
|
259
|
+
{def.osComponent && (
|
|
260
|
+
<View style={styles.osBadge}>
|
|
261
|
+
<Text style={styles.osBadgeText}>{def.osComponent === 'full' ? 'OS component' : 'Partly OS component'}</Text>
|
|
262
|
+
</View>
|
|
263
|
+
)}
|
|
252
264
|
<Text style={styles.desc}>{def.description}</Text>
|
|
253
265
|
</View>
|
|
254
266
|
{pager && (
|
|
255
267
|
<View style={styles.pager}>
|
|
256
|
-
<PagerButton direction="previous" targetId={pager.previousId} onNavigate={pager.onNavigate} />
|
|
257
|
-
<PagerButton direction="next" targetId={pager.nextId} onNavigate={pager.onNavigate} />
|
|
268
|
+
<PagerButton direction="previous" targetId={pager.previousId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
|
|
269
|
+
<PagerButton direction="next" targetId={pager.nextId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
|
|
258
270
|
</View>
|
|
259
271
|
)}
|
|
260
272
|
</View>
|
|
@@ -278,6 +290,17 @@ const styles = StyleSheet.create({
|
|
|
278
290
|
},
|
|
279
291
|
titleText: { flex: 1 },
|
|
280
292
|
headingTarget: { alignSelf: 'flex-start' },
|
|
293
|
+
osBadge: {
|
|
294
|
+
alignSelf: 'flex-start',
|
|
295
|
+
marginBottom: CATALOG_SPACE.sm,
|
|
296
|
+
paddingHorizontal: CATALOG_SPACE.sm,
|
|
297
|
+
paddingVertical: 3,
|
|
298
|
+
borderRadius: CATALOG_RADIUS.control,
|
|
299
|
+
borderWidth: 1,
|
|
300
|
+
borderColor: CATALOG_COLOR.borderStrong,
|
|
301
|
+
backgroundColor: CATALOG_COLOR.surface,
|
|
302
|
+
},
|
|
303
|
+
osBadgeText: { fontSize: CATALOG_TYPE.xs, fontWeight: '700', letterSpacing: 0.4, color: CATALOG_COLOR.text },
|
|
281
304
|
title: { fontSize: CATALOG_TYPE.pageTitle, fontWeight: '700', color: CATALOG_COLOR.text, marginBottom: 5 },
|
|
282
305
|
desc: { fontSize: CATALOG_TYPE.md, lineHeight: 20, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
|
|
283
306
|
pager: { flexDirection: 'row', gap: 6, flexShrink: 0 },
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
|
|
4
|
+
import { sectionColumns, tileGeometry, TOKEN_LAYOUT_GAP } from './tokenLayout';
|
|
5
|
+
import type { TokenSection } from './types';
|
|
6
|
+
|
|
7
|
+
/** Measures its own width; renders nothing inside until the width is known. */
|
|
8
|
+
function useWidth(): [number, (event: LayoutChangeEvent) => void] {
|
|
9
|
+
const [width, setWidth] = useState(0);
|
|
10
|
+
return [width, (event) => {
|
|
11
|
+
const next = Math.round(event.nativeEvent.layout.width);
|
|
12
|
+
if (next > 0 && next !== width) setWidth(next);
|
|
13
|
+
}];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A token page's sections (`SectionDef.tokenSections`): one titled card per kind of token, flowing
|
|
18
|
+
* in up to `columns` columns. A `wide` section takes a full row; the page drops to fewer columns
|
|
19
|
+
* rather than squeeze a section under its minimum width.
|
|
20
|
+
*/
|
|
21
|
+
export function TokenSections({ sections, columns }: { sections: TokenSection[]; columns: 1 | 2 | 3 }) {
|
|
22
|
+
const [width, onLayout] = useWidth();
|
|
23
|
+
const perRow = sectionColumns(width, columns);
|
|
24
|
+
const columnWidth = perRow === 1 ? width : (width - TOKEN_LAYOUT_GAP * (perRow - 1)) / perRow;
|
|
25
|
+
return (
|
|
26
|
+
<View onLayout={onLayout} style={styles.sections}>
|
|
27
|
+
{width > 0 && sections.map((section) => (
|
|
28
|
+
<View key={section.title} style={{ width: section.wide || perRow === 1 ? width : columnWidth }}>
|
|
29
|
+
<Text style={styles.sectionLabel}>{section.title}</Text>
|
|
30
|
+
{section.desc ? <Text style={styles.sectionDesc}>{section.desc}</Text> : null}
|
|
31
|
+
<View style={styles.card}>{section.render()}</View>
|
|
32
|
+
</View>
|
|
33
|
+
))}
|
|
34
|
+
</View>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Tokens as tiles in columns — for tokens whose preview is small and self-contained (radius,
|
|
40
|
+
* shadow, colour, control height). As many equal tiles per row as keep `minTileWidth`.
|
|
41
|
+
*/
|
|
42
|
+
export function TokenGrid({ children, minTileWidth = 140, maxColumns }: { children: React.ReactNode; minTileWidth?: number; maxColumns?: number }) {
|
|
43
|
+
const [width, onLayout] = useWidth();
|
|
44
|
+
const { tileWidth } = tileGeometry(width, minTileWidth, maxColumns);
|
|
45
|
+
return (
|
|
46
|
+
<View onLayout={onLayout} style={styles.grid}>
|
|
47
|
+
{width > 0 && React.Children.map(children, (child) => (child ? <View style={{ width: tileWidth }}>{child}</View> : null))}
|
|
48
|
+
</View>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One token in a `TokenGrid`: its preview, then its name and value, then its role. `note` says
|
|
54
|
+
* where or why the token is used (from its comments or real usages) — omit it when there is none.
|
|
55
|
+
*/
|
|
56
|
+
export function TokenTile({ name, value, note, children }: { name: string; value?: string; note?: string; children: React.ReactNode }) {
|
|
57
|
+
return (
|
|
58
|
+
<View style={styles.tile}>
|
|
59
|
+
<View style={styles.tilePreview}>{children}</View>
|
|
60
|
+
<Text style={styles.tileName}>{name}</Text>
|
|
61
|
+
{value ? <Text style={styles.tileValue}>{value}</Text> : null}
|
|
62
|
+
{note ? <Text style={styles.tileNote}>{note}</Text> : null}
|
|
63
|
+
</View>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const styles = StyleSheet.create({
|
|
68
|
+
sections: { flexDirection: 'row', flexWrap: 'wrap', gap: TOKEN_LAYOUT_GAP, rowGap: CATALOG_LAYOUT.blockGap, alignItems: 'flex-start' },
|
|
69
|
+
sectionLabel: {
|
|
70
|
+
fontSize: CATALOG_TYPE.sm,
|
|
71
|
+
fontWeight: '700',
|
|
72
|
+
letterSpacing: 0.72,
|
|
73
|
+
textTransform: 'uppercase',
|
|
74
|
+
color: CATALOG_COLOR.text,
|
|
75
|
+
},
|
|
76
|
+
sectionDesc: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: CATALOG_SPACE.xs },
|
|
77
|
+
card: {
|
|
78
|
+
marginTop: CATALOG_LAYOUT.blockLabelGap,
|
|
79
|
+
backgroundColor: CATALOG_COLOR.surface,
|
|
80
|
+
borderWidth: 1,
|
|
81
|
+
borderColor: CATALOG_COLOR.borderStrong,
|
|
82
|
+
borderRadius: CATALOG_RADIUS.card,
|
|
83
|
+
padding: CATALOG_SPACE.xl,
|
|
84
|
+
gap: CATALOG_SPACE.md,
|
|
85
|
+
},
|
|
86
|
+
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: TOKEN_LAYOUT_GAP },
|
|
87
|
+
tile: { gap: CATALOG_SPACE.xs },
|
|
88
|
+
tilePreview: { minHeight: 56, justifyContent: 'center', marginBottom: CATALOG_SPACE.xs },
|
|
89
|
+
tileName: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', color: CATALOG_COLOR.text },
|
|
90
|
+
tileValue: { fontSize: CATALOG_TYPE.xs, color: CATALOG_COLOR.textMuted },
|
|
91
|
+
tileNote: { fontSize: CATALOG_TYPE.xs, color: CATALOG_COLOR.textMuted, lineHeight: 16 },
|
|
92
|
+
});
|
|
@@ -13,17 +13,41 @@ import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE } from './tokens';
|
|
|
13
13
|
* reads as a list, not a loose pile of paragraphs. Pass `last` on the final row in a stack to drop
|
|
14
14
|
* the divider, matching PropsTable's own `rowLast` convention.
|
|
15
15
|
*/
|
|
16
|
-
export function TokenRow({
|
|
16
|
+
export function TokenRow({
|
|
17
|
+
children,
|
|
18
|
+
use,
|
|
19
|
+
last = false,
|
|
20
|
+
notePlacement = 'below',
|
|
21
|
+
sampleWidth = 280,
|
|
22
|
+
}: {
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
use: string;
|
|
25
|
+
last?: boolean;
|
|
26
|
+
/** `'right'` puts the note beside a narrow sample (icon sizes, spacing, control heights) so each
|
|
27
|
+
* token is one line; the sample keeps `sampleWidth` and the note takes the rest. */
|
|
28
|
+
notePlacement?: 'below' | 'right';
|
|
29
|
+
sampleWidth?: number;
|
|
30
|
+
}) {
|
|
31
|
+
if (notePlacement === 'right') {
|
|
32
|
+
return (
|
|
33
|
+
<View style={[styles.itemRight, !last && styles.itemDivider]}>
|
|
34
|
+
<View style={{ width: sampleWidth, flexShrink: 0 }}>{children}</View>
|
|
35
|
+
{use ? <Text style={[styles.use, styles.useRight]}>{use}</Text> : null}
|
|
36
|
+
</View>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
17
39
|
return (
|
|
18
40
|
<View style={[styles.item, !last && styles.itemDivider]}>
|
|
19
41
|
{children}
|
|
20
|
-
<Text style={styles.use}>{use}</Text>
|
|
42
|
+
{use ? <Text style={styles.use}>{use}</Text> : null}
|
|
21
43
|
</View>
|
|
22
44
|
);
|
|
23
45
|
}
|
|
24
46
|
|
|
25
47
|
const styles = StyleSheet.create({
|
|
26
48
|
item: { gap: CATALOG_SPACE.xs },
|
|
49
|
+
itemRight: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.xl },
|
|
50
|
+
useRight: { flex: 1 },
|
|
27
51
|
itemDivider: {
|
|
28
52
|
paddingBottom: CATALOG_SPACE.md,
|
|
29
53
|
borderBottomWidth: 1,
|
|
@@ -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
|
|
@@ -230,6 +230,7 @@ export type PresentationBlock =
|
|
|
230
230
|
| { kind: 'grouped'; title: string; size: SpecimenSize; groups: ListGroup[] }
|
|
231
231
|
| { kind: 'list'; title: string; size: SpecimenSize; items: ListItem[] }
|
|
232
232
|
| { kind: 'preview'; title: string; widths: PreviewWidths }
|
|
233
|
+
| { kind: 'tokenSections'; title: string; sections: TokenSection[]; columns: 1 | 2 | 3 }
|
|
233
234
|
| { kind: 'empty'; title: string; message: string };
|
|
234
235
|
|
|
235
236
|
export interface PresentationOptions {
|
|
@@ -280,6 +281,10 @@ function groupStates(variants: VariantSlot, states: VariantSlot): { groups: List
|
|
|
280
281
|
* never shows an empty block next to a real one; with nothing documented at all it shows one
|
|
281
282
|
* "No examples documented." block. */
|
|
282
283
|
export function presentationBlocks<TId extends string>(def: SectionDef<TId>, options: PresentationOptions = {}): PresentationBlock[] {
|
|
284
|
+
if (def.tokenSections && def.tokenSections.length > 0) {
|
|
285
|
+
// Each section carries its own label, so the block itself has none.
|
|
286
|
+
return [{ kind: 'tokenSections', title: '', sections: def.tokenSections, columns: def.tokenColumns ?? 1 }];
|
|
287
|
+
}
|
|
283
288
|
if (def.tokenGallery) {
|
|
284
289
|
const title = def.fullWidthLabel ?? 'Tokens';
|
|
285
290
|
return [def.render ? { kind: 'preview', title, widths: 'full' } : { kind: 'empty', title, message: 'Nothing to preview.' }];
|
package/native/catalog/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ export { ReferenceDetails } from './ReferenceDetails';
|
|
|
24
24
|
export { PropsTable } from './PropsTable';
|
|
25
25
|
export { VariantGroup } from './VariantGroup';
|
|
26
26
|
export { TokenRow } from './TokenRow';
|
|
27
|
+
export { TokenSections, TokenGrid, TokenTile } from './TokenLayouts';
|
|
27
28
|
export { DividedStack } from './DividedStack';
|
|
28
29
|
export { Swatch } from './Swatch';
|
|
29
30
|
export { PhoneFrame } from './PhoneFrame';
|
|
@@ -33,7 +34,7 @@ export { buildComponentManifest } from './manifest';
|
|
|
33
34
|
export type { ComponentManifestEntry, ManifestExample } from './manifest';
|
|
34
35
|
export { defineCatalogPage, buildCatalogSections } from './pageApi';
|
|
35
36
|
export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRecord } from './pageApi';
|
|
36
|
-
export type { PropDef, SectionDef, NavGroup, SpecimenSize, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis, PreviewWidths } from './types';
|
|
37
|
+
export type { PropDef, SectionDef, NavGroup, SpecimenSize, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis, PreviewWidths, TokenSection } from './types';
|
|
37
38
|
export { UpdatePanel } from './UpdatePanel';
|
|
38
39
|
export { UPDATE_PAGE_ID } from './catalogNavigation';
|
|
39
40
|
export type { UpdateNotice } from './types';
|
|
@@ -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, NavGroup, PreviewWidths, PropDef, SectionDef, SpecimenSize, 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,6 +24,8 @@ 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;
|
|
@@ -121,15 +131,20 @@ export function buildCatalogSections<TId extends string>(
|
|
|
121
131
|
// to the page's own file as its Source instead of an empty path.
|
|
122
132
|
return {
|
|
123
133
|
id: page.id,
|
|
134
|
+
title: page.title,
|
|
135
|
+
osComponent: page.osComponent,
|
|
124
136
|
description: page.description,
|
|
125
|
-
path: component?.file ?? page.file,
|
|
137
|
+
path: component?.file ?? page.source ?? page.file,
|
|
126
138
|
whenToUse: page.whenToUse,
|
|
127
139
|
props: component ? mergePropNotes(component, page.propNotes) : undefined,
|
|
128
140
|
a11y: page.a11y,
|
|
129
141
|
variants: page.variants,
|
|
130
142
|
states: page.states,
|
|
131
143
|
render: page.render,
|
|
132
|
-
|
|
144
|
+
// `tokenSections` makes a page a token gallery by itself.
|
|
145
|
+
tokenGallery: page.tokenGallery || (page.tokenSections !== undefined && page.tokenSections.length > 0) || undefined,
|
|
146
|
+
tokenSections: page.tokenSections,
|
|
147
|
+
tokenColumns: page.tokenColumns,
|
|
133
148
|
fullWidthLabel: page.fullWidthLabel,
|
|
134
149
|
comparison: page.comparison,
|
|
135
150
|
specimenSize: page.specimenSize,
|
|
@@ -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
|
@@ -102,6 +102,13 @@ export interface ComparisonDef {
|
|
|
102
102
|
* typed to the app's real section list without this file needing to know what they are. */
|
|
103
103
|
export interface SectionDef<TId extends string = string> {
|
|
104
104
|
id: TId;
|
|
105
|
+
/** Display name for the heading, sidebar and search (e.g. "Control heights"). Defaults to `id`,
|
|
106
|
+
* which stays the page's address (`#ControlHeights`) and its name in `explain`. */
|
|
107
|
+
title?: string;
|
|
108
|
+
/** Marks a component that is, or contains, a native platform control rather than a custom one:
|
|
109
|
+
* `'full'` shows an "OS component" badge under the title, `'partial'` "Partly OS component".
|
|
110
|
+
* Say in `description` which part is native and how it differs on web. */
|
|
111
|
+
osComponent?: 'full' | 'partial';
|
|
105
112
|
description: string;
|
|
106
113
|
path: string;
|
|
107
114
|
/** One sentence disambiguating this component from its closest look-alike(s) — the deciding
|
|
@@ -163,6 +170,14 @@ export interface SectionDef<TId extends string = string> {
|
|
|
163
170
|
* configurations list, and shows only a Quick reference card with the source path (there's no
|
|
164
171
|
* component behavior to document). */
|
|
165
172
|
tokenGallery?: boolean;
|
|
173
|
+
/** Token pages with more than one kind of token: one titled card per section (e.g. "Semantic"
|
|
174
|
+
* and "Palette", or "Body" and "Title"), instead of a single "Tokens" card. When set, `render`
|
|
175
|
+
* is not used and the page counts as a token gallery. See `TokenSection`. */
|
|
176
|
+
tokenSections?: TokenSection[];
|
|
177
|
+
/** How many sections sit side by side (1–3, default 1). Narrow sections such as type families
|
|
178
|
+
* use 2–3; the page drops columns rather than squeeze a section under 280px. A section with
|
|
179
|
+
* `wide: true` always takes a full row. */
|
|
180
|
+
tokenColumns?: 1 | 2 | 3;
|
|
166
181
|
/** Overrides the `tokenGallery` block's label (default `'Tokens'`) — e.g. `'Preview'` for a page
|
|
167
182
|
* that's a composed, realistic usage example rather than a list of raw token values. Ignored
|
|
168
183
|
* unless `tokenGallery` is also set. */
|
|
@@ -206,3 +221,14 @@ export interface UpdateNotice {
|
|
|
206
221
|
summary: string[];
|
|
207
222
|
releasedAt?: string;
|
|
208
223
|
}
|
|
224
|
+
|
|
225
|
+
/** One titled card on a token page (`SectionDef.tokenSections`). */
|
|
226
|
+
export interface TokenSection {
|
|
227
|
+
/** The kind of token this card holds, e.g. "Semantic colours", "Palette", "Body". */
|
|
228
|
+
title: string;
|
|
229
|
+
/** One line under the title: what this kind of token is for. */
|
|
230
|
+
desc?: string;
|
|
231
|
+
/** Take a full row even when the page sets `tokenColumns` (e.g. palette ramps). */
|
|
232
|
+
wide?: boolean;
|
|
233
|
+
render: () => React.ReactNode;
|
|
234
|
+
}
|