@krapwoo/ds-viewer 0.4.6 → 0.4.8
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 +121 -21
- package/dist/cli/doctor.js +141 -2
- package/dist/cli/explain.js +44 -6
- package/dist/cli/init.js +5 -8
- package/dist/cli/props.js +80 -1
- package/dist/cli/staticPage.js +228 -6
- package/dist/native/catalog/comparison.js +43 -9
- package/native/catalog/BoundedOverlayViewport.tsx +112 -0
- package/native/catalog/CatalogShell.tsx +26 -0
- package/native/catalog/ComparisonGrid.tsx +6 -2
- package/native/catalog/ComparisonGroups.tsx +8 -2
- package/native/catalog/ComparisonList.tsx +8 -2
- package/native/catalog/PhoneFrame.tsx +109 -19
- package/native/catalog/ReferenceDetails.tsx +83 -20
- package/native/catalog/SectionBlock.tsx +75 -7
- package/native/catalog/SpecimenAddress.tsx +15 -0
- package/native/catalog/SpecimenSurface.tsx +80 -10
- package/native/catalog/comparison.ts +57 -10
- package/native/catalog/deviceFrame.ts +14 -0
- package/native/catalog/index.ts +4 -0
- package/native/catalog/overlayViewport.ts +158 -0
- package/native/catalog/pageApi.ts +6 -0
- package/native/catalog/specimenSurfaceStyle.ts +124 -1
- package/native/catalog/types.ts +42 -12
- package/package.json +5 -4
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -1
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -1
- package/starter-kit/components/Badge/Badge.catalog.tsx +1 -1
- package/starter-kit/components/Banner/Banner.catalog.tsx +5 -1
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +5 -1
- package/starter-kit/components/Button/Button.catalog.tsx +4 -1
- package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +8 -1
- package/starter-kit/components/Card/Card.catalog.tsx +2 -1
- package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +2 -2
- package/starter-kit/components/Dialog/Dialog.catalog.tsx +1 -1
- package/starter-kit/components/Divider/Divider.catalog.tsx +2 -1
- package/starter-kit/components/Dock/Dock.catalog.tsx +4 -1
- package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +7 -1
- package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +7 -1
- package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +5 -1
- package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +1 -1
- package/starter-kit/components/InputField/InputField.catalog.tsx +6 -1
- package/starter-kit/components/List/List.catalog.tsx +5 -1
- package/starter-kit/components/ListItem/ListItem.catalog.tsx +7 -1
- package/starter-kit/components/Loading/Loading.catalog.tsx +2 -1
- package/starter-kit/components/Pill/Pill.catalog.tsx +4 -1
- package/starter-kit/components/PillRow/PillRow.catalog.tsx +4 -1
- package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +1 -1
- package/starter-kit/components/Radio/Radio.catalog.tsx +2 -2
- package/starter-kit/components/SearchField/SearchField.catalog.tsx +6 -1
- package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +5 -1
- package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +1 -3
- package/starter-kit/components/Shimmer/Breathing.catalog.tsx +2 -1
- package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +2 -1
- package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +5 -1
- package/starter-kit/components/Surface/Surface.catalog.tsx +2 -1
- package/starter-kit/components/Switch/Switch.catalog.tsx +2 -2
- package/starter-kit/components/TextArea/TextArea.catalog.tsx +5 -1
- package/starter-kit/components/Toast/Toast.catalog.tsx +9 -1
- package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +2 -1
- package/starter-kit/components/TopNav/TopNav.catalog.tsx +4 -1
- package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +1 -3
- package/starter-kit/pages/ReportIssue.catalog.tsx +18 -1
- package/starter-kit/pages/SavedTrips.catalog.tsx +27 -6
- package/starter-kit/pages/TripPlannerForm.catalog.tsx +7 -1
package/dist/cli/staticPage.js
CHANGED
|
@@ -140,7 +140,7 @@ function readStaticPage(file) {
|
|
|
140
140
|
else
|
|
141
141
|
page.checkable = false;
|
|
142
142
|
}
|
|
143
|
-
const variants = readSlotItems(findProp(pageObject, 'variants'), consts);
|
|
143
|
+
const variants = readSlotItems(findProp(pageObject, 'variants'), consts, page.component, sourceFile);
|
|
144
144
|
if (!variants.checkable)
|
|
145
145
|
page.checkable = false;
|
|
146
146
|
else {
|
|
@@ -148,7 +148,7 @@ function readStaticPage(file) {
|
|
|
148
148
|
page.variantsItemsFill = variants.itemsFill;
|
|
149
149
|
page.variantsMaxColumns = variants.maxColumns;
|
|
150
150
|
}
|
|
151
|
-
const states = readSlotItems(findProp(pageObject, 'states'), consts);
|
|
151
|
+
const states = readSlotItems(findProp(pageObject, 'states'), consts, page.component, sourceFile);
|
|
152
152
|
if (!states.checkable)
|
|
153
153
|
page.checkable = false;
|
|
154
154
|
else {
|
|
@@ -156,8 +156,42 @@ function readStaticPage(file) {
|
|
|
156
156
|
page.statesItemsFill = states.itemsFill;
|
|
157
157
|
page.statesMaxColumns = states.maxColumns;
|
|
158
158
|
}
|
|
159
|
+
const composedOfProp = findProp(pageObject, 'composedOf');
|
|
160
|
+
page.hasComposedOf = composedOfProp !== undefined;
|
|
161
|
+
if (composedOfProp)
|
|
162
|
+
page.composedOf = readComposedOf(composedOfProp.initializer, consts);
|
|
163
|
+
const intentionalStaticPreviewProp = findProp(pageObject, 'intentionalStaticPreview');
|
|
164
|
+
if (intentionalStaticPreviewProp) {
|
|
165
|
+
const resolved = resolve(intentionalStaticPreviewProp.initializer, consts, new Set());
|
|
166
|
+
if (ts.isObjectLiteralExpression(resolved)) {
|
|
167
|
+
const reasonProp = findProp(resolved, 'reason');
|
|
168
|
+
if (reasonProp)
|
|
169
|
+
page.intentionalStaticPreviewReason = stringLiteral(resolve(reasonProp.initializer, consts, new Set()));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
159
172
|
return page;
|
|
160
173
|
}
|
|
174
|
+
/** A literal array of `ComposedOfEntry`-shaped object literals, read down to just `component`
|
|
175
|
+
* (design §4: role/relationship are authored prose `doctor` never needs for its missing-
|
|
176
|
+
* composition comparison). Undefined — not an empty array — for anything that isn't itself a
|
|
177
|
+
* literal array, or whose element isn't an object literal with a literal `component`: `doctor`
|
|
178
|
+
* must never mistake "couldn't read this" for "author declared no composition here". */
|
|
179
|
+
function readComposedOf(node, consts) {
|
|
180
|
+
const resolved = resolve(node, consts, new Set());
|
|
181
|
+
if (!ts.isArrayLiteralExpression(resolved))
|
|
182
|
+
return undefined;
|
|
183
|
+
const entries = [];
|
|
184
|
+
for (const element of resolved.elements) {
|
|
185
|
+
const resolvedElement = resolve(element, consts, new Set());
|
|
186
|
+
if (!ts.isObjectLiteralExpression(resolvedElement))
|
|
187
|
+
return undefined;
|
|
188
|
+
const component = propertyStringLiteral(resolvedElement, 'component', consts);
|
|
189
|
+
if (component === undefined)
|
|
190
|
+
return undefined;
|
|
191
|
+
entries.push({ component });
|
|
192
|
+
}
|
|
193
|
+
return entries;
|
|
194
|
+
}
|
|
161
195
|
/** Local name -> imported name, for every named import from `moduleSpecifier`. */
|
|
162
196
|
function importedNames(sourceFile, moduleSpecifier) {
|
|
163
197
|
const names = new Map();
|
|
@@ -219,6 +253,20 @@ function resolve(node, consts, seen) {
|
|
|
219
253
|
function stringLiteral(node) {
|
|
220
254
|
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : undefined;
|
|
221
255
|
}
|
|
256
|
+
/** `true`/`false` only from a known `true`/`false` literal; any other expression (a variable, a
|
|
257
|
+
* computed value) stays `undefined` rather than being misread as `false`. Unlike the page's own
|
|
258
|
+
* `hide.*`/`tokenGallery`/`itemsFill`/`wide` booleans — which intentionally treat "not literally
|
|
259
|
+
* `true`" as `false`, since those already default to `false` when omitted — `fill`'s own default
|
|
260
|
+
* is inherited (`SpecimenPresentation.fill` defaults to the slot's `itemsFill`, not to `false`),
|
|
261
|
+
* so a non-literal value here must stay honestly unknown instead of claiming "not filling". */
|
|
262
|
+
function literalBooleanOrUnknown(node, consts) {
|
|
263
|
+
const resolved = resolve(node, consts, new Set());
|
|
264
|
+
if (resolved.kind === ts.SyntaxKind.TrueKeyword)
|
|
265
|
+
return true;
|
|
266
|
+
if (resolved.kind === ts.SyntaxKind.FalseKeyword)
|
|
267
|
+
return false;
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
222
270
|
function propertyName(name) {
|
|
223
271
|
return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : name.getText();
|
|
224
272
|
}
|
|
@@ -271,6 +319,34 @@ function readAxis(node, consts) {
|
|
|
271
319
|
}
|
|
272
320
|
return undefined;
|
|
273
321
|
}
|
|
322
|
+
/** A literal array of `ComparisonCell`-shaped object literals: `rowKey`/`columnKey` required
|
|
323
|
+
* (string literals only), `surface`/`align`/`fill` read the same per-field-lenient way
|
|
324
|
+
* `readListItems` reads them. `node`/`unavailableReason` are never inspected — design §4 never
|
|
325
|
+
* evaluates a cell's specimen node. Undefined (no cell metadata at all, not "every field unset")
|
|
326
|
+
* for anything that isn't itself a literal array, or whose element isn't itself an object literal
|
|
327
|
+
* with literal `rowKey`/`columnKey` — a `.flatMap()`-built `cells` array (the starter kit's own
|
|
328
|
+
* convention for a `grid()`-free literal comparison) stays unrepresented rather than guessed at. */
|
|
329
|
+
function readComparisonCells(node, consts) {
|
|
330
|
+
const resolved = resolve(node, consts, new Set());
|
|
331
|
+
if (!ts.isArrayLiteralExpression(resolved))
|
|
332
|
+
return undefined;
|
|
333
|
+
const cells = [];
|
|
334
|
+
for (const element of resolved.elements) {
|
|
335
|
+
const resolvedElement = resolve(element, consts, new Set());
|
|
336
|
+
if (!ts.isObjectLiteralExpression(resolvedElement))
|
|
337
|
+
return undefined;
|
|
338
|
+
const rowKey = propertyStringLiteral(resolvedElement, 'rowKey', consts);
|
|
339
|
+
const columnKey = propertyStringLiteral(resolvedElement, 'columnKey', consts);
|
|
340
|
+
if (rowKey === undefined || columnKey === undefined)
|
|
341
|
+
return undefined;
|
|
342
|
+
const surface = propertyStringLiteral(resolvedElement, 'surface', consts);
|
|
343
|
+
const align = propertyStringLiteral(resolvedElement, 'align', consts);
|
|
344
|
+
const fillProp = findProp(resolvedElement, 'fill');
|
|
345
|
+
const fill = fillProp ? literalBooleanOrUnknown(fillProp.initializer, consts) : undefined;
|
|
346
|
+
cells.push({ rowKey, columnKey, surface, align, fill });
|
|
347
|
+
}
|
|
348
|
+
return cells;
|
|
349
|
+
}
|
|
274
350
|
/** A page's `comparison` field: either a direct `ComparisonDef`-shaped object literal, or (the
|
|
275
351
|
* starter kit's own convention since Task 1/2) a call to this package's own `grid()` helper — the
|
|
276
352
|
* one call shape this reader specially recognizes, reading its `rows`/`columns` arguments and
|
|
@@ -305,12 +381,14 @@ function readComparison(node, consts, gridNames) {
|
|
|
305
381
|
const rowLabelProp = findProp(resolved, 'rowLabel');
|
|
306
382
|
const columnLabelProp = findProp(resolved, 'columnLabel');
|
|
307
383
|
const sizeProp = findProp(resolved, 'size');
|
|
384
|
+
const cellsProp = findProp(resolved, 'cells');
|
|
308
385
|
return {
|
|
309
386
|
rowLabel: rowLabelProp ? stringLiteral(resolve(rowLabelProp.initializer, consts, new Set())) : undefined,
|
|
310
387
|
columnLabel: columnLabelProp ? stringLiteral(resolve(columnLabelProp.initializer, consts, new Set())) : undefined,
|
|
311
388
|
rows,
|
|
312
389
|
columns,
|
|
313
390
|
size: sizeProp ? stringLiteral(resolve(sizeProp.initializer, consts, new Set())) : undefined,
|
|
391
|
+
cells: cellsProp ? readComparisonCells(cellsProp.initializer, consts) : undefined,
|
|
314
392
|
};
|
|
315
393
|
}
|
|
316
394
|
return undefined;
|
|
@@ -318,7 +396,7 @@ function readComparison(node, consts, gridNames) {
|
|
|
318
396
|
/** A literal array of list-item object literals — `key`/`name` required; `props` (read as a flat
|
|
319
397
|
* literal map, non-literal values simply omitted) and `group` optional. Each item's `node` field
|
|
320
398
|
* (always present, always JSX) is never inspected — reading the sibling fields never depends on it. */
|
|
321
|
-
function readListItems(node, consts) {
|
|
399
|
+
function readListItems(node, consts, componentName, sourceFile) {
|
|
322
400
|
const resolved = resolve(node, consts, new Set());
|
|
323
401
|
if (!ts.isArrayLiteralExpression(resolved))
|
|
324
402
|
return undefined;
|
|
@@ -333,10 +411,154 @@ function readListItems(node, consts) {
|
|
|
333
411
|
return undefined;
|
|
334
412
|
const group = propertyStringLiteral(resolvedElement, 'group', consts);
|
|
335
413
|
const propsProp = findProp(resolvedElement, 'props');
|
|
336
|
-
|
|
414
|
+
const fillProp = findProp(resolvedElement, 'fill');
|
|
415
|
+
const fill = fillProp ? literalBooleanOrUnknown(fillProp.initializer, consts) : undefined;
|
|
416
|
+
const surface = propertyStringLiteral(resolvedElement, 'surface', consts);
|
|
417
|
+
const align = propertyStringLiteral(resolvedElement, 'align', consts);
|
|
418
|
+
const nodeProp = findProp(resolvedElement, 'node');
|
|
419
|
+
const nodeExample = nodeProp ? readNodeExample(nodeProp.initializer, consts, componentName, sourceFile) : {};
|
|
420
|
+
items.push({
|
|
421
|
+
key, name, group, props: propsProp ? readLiteralProps(propsProp.initializer, consts) : undefined, fill, surface, align,
|
|
422
|
+
...nodeExample,
|
|
423
|
+
});
|
|
337
424
|
}
|
|
338
425
|
return items;
|
|
339
426
|
}
|
|
427
|
+
/** Generic, additive reading of one item's own `node:` JSX (design §3, plan Task 3) — used only by
|
|
428
|
+
* `cli/doctor.ts`'s interactive-preview advisory. Never evaluates `node`; only reads its literal
|
|
429
|
+
* AST shape. `node` matching `componentName` by plain tag-name text (not module resolution — this
|
|
430
|
+
* reader never resolves imports) means "a direct instance of the documented component": its own
|
|
431
|
+
* JSX attributes are read generically (`nodeAttributes`). Any other JSX tag is checked only for
|
|
432
|
+
* whether it's a confidently-identified, genuinely stateful same-file wrapper
|
|
433
|
+
* (`nodeStatefulWrapper`) — never asserted one way or the other when that can't be confirmed.
|
|
434
|
+
* Anything that isn't a direct JSX element at all (`null`, a conditional, a function call, …)
|
|
435
|
+
* leaves every field unset. */
|
|
436
|
+
function readNodeExample(node, consts, componentName, sourceFile) {
|
|
437
|
+
const resolved = resolve(node, consts, new Set());
|
|
438
|
+
let element;
|
|
439
|
+
if (ts.isJsxSelfClosingElement(resolved))
|
|
440
|
+
element = resolved;
|
|
441
|
+
else if (ts.isJsxElement(resolved))
|
|
442
|
+
element = resolved.openingElement;
|
|
443
|
+
if (!element || !ts.isIdentifier(element.tagName))
|
|
444
|
+
return {};
|
|
445
|
+
const tagText = element.tagName.text;
|
|
446
|
+
if (componentName !== undefined && tagText === componentName) {
|
|
447
|
+
const nodeAttributes = {};
|
|
448
|
+
for (const attribute of element.attributes.properties) {
|
|
449
|
+
// Skips a spread attribute ({...rest}) and a namespaced name (xml:lang) — unreadable,
|
|
450
|
+
// never a reason to reject the whole item.
|
|
451
|
+
if (!ts.isJsxAttribute(attribute) || !ts.isIdentifier(attribute.name))
|
|
452
|
+
continue;
|
|
453
|
+
nodeAttributes[attribute.name.text] = readJsxAttributeValue(attribute, consts);
|
|
454
|
+
}
|
|
455
|
+
return { nodeDirectComponent: tagText, nodeAttributes };
|
|
456
|
+
}
|
|
457
|
+
return { nodeStatefulWrapper: sameFileWrapperHasUseState(sourceFile, tagText) ? true : undefined };
|
|
458
|
+
}
|
|
459
|
+
/** One JSX attribute's value, as a `StaticAttributeValue`. A shorthand attribute (`disabled`, no
|
|
460
|
+
* `initializer` at all — equivalent to `disabled={true}`) is a literal `true`. */
|
|
461
|
+
function readJsxAttributeValue(attribute, consts) {
|
|
462
|
+
if (!attribute.initializer)
|
|
463
|
+
return { kind: 'literal', value: true };
|
|
464
|
+
if (ts.isStringLiteral(attribute.initializer))
|
|
465
|
+
return { kind: 'literal', value: attribute.initializer.text };
|
|
466
|
+
if (!ts.isJsxExpression(attribute.initializer) || !attribute.initializer.expression)
|
|
467
|
+
return { kind: 'unknown' };
|
|
468
|
+
const resolved = resolve(attribute.initializer.expression, consts, new Set());
|
|
469
|
+
if (ts.isStringLiteral(resolved) || ts.isNoSubstitutionTemplateLiteral(resolved))
|
|
470
|
+
return { kind: 'literal', value: resolved.text };
|
|
471
|
+
if (ts.isNumericLiteral(resolved))
|
|
472
|
+
return { kind: 'literal', value: Number(resolved.text) };
|
|
473
|
+
if (resolved.kind === ts.SyntaxKind.TrueKeyword)
|
|
474
|
+
return { kind: 'literal', value: true };
|
|
475
|
+
if (resolved.kind === ts.SyntaxKind.FalseKeyword)
|
|
476
|
+
return { kind: 'literal', value: false };
|
|
477
|
+
if (ts.isIdentifier(resolved) && resolved.text === 'undefined')
|
|
478
|
+
return { kind: 'no-op-callback' };
|
|
479
|
+
if ((ts.isArrowFunction(resolved) || ts.isFunctionExpression(resolved)) && isEmptyFunctionBody(resolved.body)) {
|
|
480
|
+
return { kind: 'no-op-callback' };
|
|
481
|
+
}
|
|
482
|
+
return { kind: 'unknown' };
|
|
483
|
+
}
|
|
484
|
+
function isEmptyFunctionBody(body) {
|
|
485
|
+
return ts.isBlock(body) && body.statements.length === 0;
|
|
486
|
+
}
|
|
487
|
+
/** True only when `sourceFile` declares a top-level function (declaration, or a `const`/`let`
|
|
488
|
+
* initialized with a function/arrow expression) named `tagName` whose own body contains a call to
|
|
489
|
+
* a *source-backed* React `useState`/`React.useState` — a confidently-identified, genuinely
|
|
490
|
+
* stateful wrapper. Walking only that one function's own body (never the whole file) keeps this
|
|
491
|
+
* scoped the same way `cli/props.ts`'s composedOfCandidate reader scopes its own JSX walk to one
|
|
492
|
+
* implementation. */
|
|
493
|
+
function sameFileWrapperHasUseState(sourceFile, tagName) {
|
|
494
|
+
const bindings = reactUseStateBindings(sourceFile);
|
|
495
|
+
for (const statement of sourceFile.statements) {
|
|
496
|
+
let body;
|
|
497
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === tagName)
|
|
498
|
+
body = statement.body;
|
|
499
|
+
else if (ts.isVariableStatement(statement)) {
|
|
500
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
501
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === tagName && declaration.initializer &&
|
|
502
|
+
(ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) {
|
|
503
|
+
body = declaration.initializer.body;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (body && containsUseStateCall(body, bindings))
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
/** This file's own top-level `import ... from 'react'` bindings relevant to recognizing a genuine
|
|
513
|
+
* `useState` call — never a type-checker resolution, only the same-file import syntax, matching
|
|
514
|
+
* this reader's existing "never resolves imports across files" contract. `namedUseState` holds
|
|
515
|
+
* every local name bound to the real named `useState` export (an alias, e.g. `import { useState as
|
|
516
|
+
* useReactState }`, included); `reactNamespace` holds every local name bound to the whole `react`
|
|
517
|
+
* module (a default or `* as` import) that a `React.useState(...)` call could be written through.
|
|
518
|
+
* A locally-defined `useState` (no matching import) or an unrelated object's own `.useState` method
|
|
519
|
+
* binds to neither set and so is never counted — left uncheckable, never asserted stateful. */
|
|
520
|
+
function reactUseStateBindings(sourceFile) {
|
|
521
|
+
const namedUseState = new Set();
|
|
522
|
+
const reactNamespace = new Set();
|
|
523
|
+
for (const statement of sourceFile.statements) {
|
|
524
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== 'react')
|
|
525
|
+
continue;
|
|
526
|
+
const clause = statement.importClause;
|
|
527
|
+
if (!clause)
|
|
528
|
+
continue;
|
|
529
|
+
if (clause.name)
|
|
530
|
+
reactNamespace.add(clause.name.text);
|
|
531
|
+
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings))
|
|
532
|
+
reactNamespace.add(clause.namedBindings.name.text);
|
|
533
|
+
else if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
|
534
|
+
for (const element of clause.namedBindings.elements) {
|
|
535
|
+
if ((element.propertyName ?? element.name).text === 'useState')
|
|
536
|
+
namedUseState.add(element.name.text);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return { namedUseState, reactNamespace };
|
|
541
|
+
}
|
|
542
|
+
function containsUseStateCall(node, bindings) {
|
|
543
|
+
let found = false;
|
|
544
|
+
const visit = (n) => {
|
|
545
|
+
if (found)
|
|
546
|
+
return;
|
|
547
|
+
if (ts.isCallExpression(n)) {
|
|
548
|
+
const callee = n.expression;
|
|
549
|
+
const isNamedUseState = ts.isIdentifier(callee) && bindings.namedUseState.has(callee.text);
|
|
550
|
+
const isReactNamespaceUseState = ts.isPropertyAccessExpression(callee) && callee.name.text === 'useState' &&
|
|
551
|
+
ts.isIdentifier(callee.expression) && bindings.reactNamespace.has(callee.expression.text);
|
|
552
|
+
if (isNamedUseState || isReactNamespaceUseState) {
|
|
553
|
+
found = true;
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
ts.forEachChild(n, visit);
|
|
558
|
+
};
|
|
559
|
+
visit(node);
|
|
560
|
+
return found;
|
|
561
|
+
}
|
|
340
562
|
function readLiteralProps(node, consts) {
|
|
341
563
|
const resolved = resolve(node, consts, new Set());
|
|
342
564
|
if (!ts.isObjectLiteralExpression(resolved))
|
|
@@ -365,14 +587,14 @@ function readLiteralProps(node, consts) {
|
|
|
365
587
|
* literal — like `previewWidths`/`fullWidthLabel`, it isn't in the original literal-checkable
|
|
366
588
|
* list, so a non-literal `itemsFill` is simply left unset. `maxColumns` does affect the geometry
|
|
367
589
|
* `explain` reports, so it must be a literal integer from 1–5 or the page is not checkable. */
|
|
368
|
-
function readSlotItems(prop, consts) {
|
|
590
|
+
function readSlotItems(prop, consts, componentName, sourceFile) {
|
|
369
591
|
if (!prop)
|
|
370
592
|
return { checkable: true };
|
|
371
593
|
const resolved = resolve(prop.initializer, consts, new Set());
|
|
372
594
|
const itemsProp = ts.isObjectLiteralExpression(resolved) ? findProp(resolved, 'items') : undefined;
|
|
373
595
|
if (!itemsProp)
|
|
374
596
|
return { checkable: false };
|
|
375
|
-
const items = readListItems(itemsProp.initializer, consts);
|
|
597
|
+
const items = readListItems(itemsProp.initializer, consts, componentName, sourceFile);
|
|
376
598
|
if (!items)
|
|
377
599
|
return { checkable: false };
|
|
378
600
|
const itemsFillProp = ts.isObjectLiteralExpression(resolved) ? findProp(resolved, 'itemsFill') : undefined;
|
|
@@ -15,6 +15,12 @@ export const PLACEMENT_MIN_SAVING = 120;
|
|
|
15
15
|
export const PROPS_MIN_COLUMN_WIDTH = 360;
|
|
16
16
|
/** Gap between the two Props columns (CATALOG_SPACE['2xl']). */
|
|
17
17
|
export const PROPS_COLUMN_GAP = 32;
|
|
18
|
+
/** A reference column (Guidance, Quick reference, or Composition) is never narrower than this;
|
|
19
|
+
* below it, `referenceColumns` drops to fewer columns. */
|
|
20
|
+
export const REFERENCE_MIN_COLUMN_WIDTH = 280;
|
|
21
|
+
/** Gap between reference columns (CATALOG_SPACE['2xl'], the same token the card's columns already
|
|
22
|
+
* use). */
|
|
23
|
+
export const REFERENCE_COLUMN_GAP = 32;
|
|
18
24
|
/** Smallest column or cell width per specimen size. Wide specimens are fixed at phone width. */
|
|
19
25
|
export const COLUMN_MIN_WIDTH = { compact: 160, regular: 240, wide: 402 };
|
|
20
26
|
/** `Array.isArray`'s built-in type guard narrows to a mutable `any[]`, which a `readonly T[]`
|
|
@@ -183,8 +189,19 @@ export function remainingStates(states, comparison) {
|
|
|
183
189
|
export function slotSize(def, slot) {
|
|
184
190
|
return def.specimenSize ?? (slot.itemsFill ? 'wide' : 'regular');
|
|
185
191
|
}
|
|
186
|
-
|
|
187
|
-
|
|
192
|
+
/** Full-width (filled) examples are block components (cards, banners, rows): narrower than about a
|
|
193
|
+
* third of a laptop's content width they stop reading as themselves, so they wrap at most 3
|
|
194
|
+
* across unless the page sets its own `maxColumns`. Small centred examples wrap freely. */
|
|
195
|
+
export const FILLED_SLOT_MAX_COLUMNS = 3;
|
|
196
|
+
function slotColumnCap(slot) {
|
|
197
|
+
if (slot.maxColumns !== undefined)
|
|
198
|
+
return { maxColumns: slot.maxColumns };
|
|
199
|
+
return slot.itemsFill ? { maxColumns: FILLED_SLOT_MAX_COLUMNS } : {};
|
|
200
|
+
}
|
|
201
|
+
function slotItems(slot, from) {
|
|
202
|
+
// Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
|
|
203
|
+
// `itemsFill: true`, which `false || slot.itemsFill` would silently discard.
|
|
204
|
+
return slot.items.map((item) => ({ key: item.key, slot: from, label: item.name, node: item.node, fill: item.fill ?? slot.itemsFill, surface: item.surface, align: item.align }));
|
|
188
205
|
}
|
|
189
206
|
/** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
|
|
190
207
|
function previewWidths(requested, fallback) {
|
|
@@ -198,12 +215,14 @@ function groupStates(variants, states) {
|
|
|
198
215
|
const variantKeys = new Set(variants.items.map((v) => v.key));
|
|
199
216
|
if (!states.items.some((s) => s.group !== undefined && variantKeys.has(s.group)))
|
|
200
217
|
return undefined;
|
|
201
|
-
|
|
218
|
+
// Nullish, not `||` — same reasoning as `slotItems`: an explicit `fill: false` must win over an
|
|
219
|
+
// inherited `itemsFill: true`.
|
|
220
|
+
const fill = (item, slot) => item.fill ?? slot.itemsFill;
|
|
202
221
|
const groups = variants.items.map((variant) => {
|
|
203
222
|
const own = states.items.filter((s) => s.group === variant.key);
|
|
204
223
|
const items = own.length > 0
|
|
205
|
-
? own.map((s) => ({ key: s.key, label: s.name, node: s.node, fill: fill(s, states) }))
|
|
206
|
-
: [{ key: variant.key, label: variant.name, node: variant.node, fill: fill(variant, variants) }];
|
|
224
|
+
? own.map((s) => ({ key: s.key, slot: 'states', label: s.name, node: s.node, fill: fill(s, states), surface: s.surface, align: s.align }))
|
|
225
|
+
: [{ key: variant.key, slot: 'variants', label: variant.name, node: variant.node, fill: fill(variant, variants), surface: variant.surface, align: variant.align }];
|
|
207
226
|
return { key: variant.key, label: variant.name, items };
|
|
208
227
|
});
|
|
209
228
|
const rest = states.items.filter((s) => s.group === undefined || !variantKeys.has(s.group));
|
|
@@ -250,8 +269,8 @@ export function presentationBlocks(def, options = {}) {
|
|
|
250
269
|
kind: 'list',
|
|
251
270
|
title: 'Variants',
|
|
252
271
|
size: slotSize(def, def.variants),
|
|
253
|
-
items: slotItems(def.variants),
|
|
254
|
-
...(def.variants
|
|
272
|
+
items: slotItems(def.variants, 'variants'),
|
|
273
|
+
...slotColumnCap(def.variants),
|
|
255
274
|
});
|
|
256
275
|
}
|
|
257
276
|
else if (def.render) {
|
|
@@ -264,8 +283,8 @@ export function presentationBlocks(def, options = {}) {
|
|
|
264
283
|
kind: 'list',
|
|
265
284
|
title: secondary ? 'Other configurations' : 'States / configurations',
|
|
266
285
|
size: slotSize(def, states),
|
|
267
|
-
items: slotItems(states),
|
|
268
|
-
...(states
|
|
286
|
+
items: slotItems(states, 'states'),
|
|
287
|
+
...slotColumnCap(states),
|
|
269
288
|
});
|
|
270
289
|
}
|
|
271
290
|
if (blocks.length === 0 && !hide.variants && !hide.states) {
|
|
@@ -297,3 +316,18 @@ export function choosePlacement({ available, gap, first, second }) {
|
|
|
297
316
|
export function propsColumns(count, width) {
|
|
298
317
|
return count >= 4 && width >= 2 * PROPS_MIN_COLUMN_WIDTH + PROPS_COLUMN_GAP ? 2 : 1;
|
|
299
318
|
}
|
|
319
|
+
/** How many columns the reference card's Guidance / Quick reference / Composition panel uses at a
|
|
320
|
+
* given measured inner card width: 3 only when there is confirmed composition to show as its own
|
|
321
|
+
* column AND the card is wide enough for three REFERENCE_MIN_COLUMN_WIDTH columns; 2 when wide
|
|
322
|
+
* enough for two (any composition then stays nested under Quick reference, as it always has been);
|
|
323
|
+
* 1 (stacked, full content in document order — nothing dropped) when even two columns wouldn't
|
|
324
|
+
* each keep their minimum. An unmeasured width (`<= 0`) stacks, so the first frame before layout
|
|
325
|
+
* settles never overflows. */
|
|
326
|
+
export function referenceColumns(width, hasComposition) {
|
|
327
|
+
const fits = (n) => width >= n * REFERENCE_MIN_COLUMN_WIDTH + (n - 1) * REFERENCE_COLUMN_GAP;
|
|
328
|
+
if (hasComposition && fits(3))
|
|
329
|
+
return 3;
|
|
330
|
+
if (fits(2))
|
|
331
|
+
return 2;
|
|
332
|
+
return 1;
|
|
333
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Platform, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_TYPE } from './tokens';
|
|
4
|
+
import { isFiniteOverlaySize, overlayStageDimensions, resolveOverlayViewport, type OverlaySlot } from './overlayViewport';
|
|
5
|
+
|
|
6
|
+
function isWeb(): boolean {
|
|
7
|
+
return Platform.OS === 'web' && typeof window !== 'undefined';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BoundedOverlayViewportProps {
|
|
11
|
+
/** This specimen's own page id — `SectionDef.id`. */
|
|
12
|
+
pageId: string;
|
|
13
|
+
/** Which block the demo wrapped here is authored in. */
|
|
14
|
+
slot: OverlaySlot;
|
|
15
|
+
/** That item's own key: `VariantExample.key` for `'variants'`/`'states'`, or
|
|
16
|
+
* `overlayViewport.ts`'s own `comparisonCellItemKey(rowKey, columnKey)` for a `'comparison'`
|
|
17
|
+
* cell — never inferred. */
|
|
18
|
+
itemKey: string;
|
|
19
|
+
/** `height` is the child document's exact usable viewport height, in CSS pixels — never
|
|
20
|
+
* renegotiated. `width` is only a MAXIMUM: this component adds no border or padding of its own
|
|
21
|
+
* (unlike `PhoneFrame`'s cosmetic hairline border), so this is the outer stage's widest possible
|
|
22
|
+
* size, but the stage still fills and shrinks to whatever its real owning specimen cell actually
|
|
23
|
+
* gives it at narrower widths — a fixed pixel width previously let the stage overflow a
|
|
24
|
+
* narrower owner and get silently clipped by it (see `overlayStageDimensions`). Both must be
|
|
25
|
+
* finite and strictly positive (`isFiniteOverlaySize`); an invalid value renders an honest
|
|
26
|
+
* unavailable state instead of an iframe sized to a meaningless bound. */
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
/** The real, unmodified demo — the same node that would otherwise render directly on the page. */
|
|
30
|
+
children: React.ReactNode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* An opt-in, bounded browsing context for a specimen whose demo opens a modal/sheet/portal that
|
|
35
|
+
* would otherwise escape its own card and cover the whole catalog page (RNW's Modal portals into
|
|
36
|
+
* `document.body`, so no styled wrapper or `overflow: hidden` can give it a different boundary —
|
|
37
|
+
* see the design's §5 "Bounded overlay viewport"). Opens a same-origin child document that boots
|
|
38
|
+
* the SAME app entry/module/provider tree this catalog page itself runs under, sized to
|
|
39
|
+
* `width`×`height`, so the demo's own overlay opens inside that rectangle instead of the outer
|
|
40
|
+
* document's body — the real production component, unmodified: no renderer is copied, no prop is
|
|
41
|
+
* mutated, no callback/source string is serialized or evaluated.
|
|
42
|
+
*
|
|
43
|
+
* Opt-in and additive: a page that never imports this renders exactly as it did before. Purely a
|
|
44
|
+
* documentation device, like `PhoneFrame` — a real app screen never frames its own overlays this
|
|
45
|
+
* way, since the actual device viewport already is the boundary.
|
|
46
|
+
*
|
|
47
|
+
* Browser-only containment: there is no native equivalent (React Native has no child-document
|
|
48
|
+
* boundary to open), so on a native runtime this renders `children` directly, with no boundary.
|
|
49
|
+
* Call sites must not read that fallback as native parity for the contained behavior.
|
|
50
|
+
*/
|
|
51
|
+
export function BoundedOverlayViewport({ pageId, slot, itemKey, width, height, children }: BoundedOverlayViewportProps) {
|
|
52
|
+
const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable'>('loading');
|
|
53
|
+
const web = isWeb();
|
|
54
|
+
const decision = resolveOverlayViewport({
|
|
55
|
+
address: { pageId, slot, itemKey },
|
|
56
|
+
isWeb: web,
|
|
57
|
+
currentSearch: web ? window.location.search : '',
|
|
58
|
+
pathname: web ? window.location.pathname : '',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Not web, or this document IS the selected child for this exact address: render the real demo
|
|
62
|
+
// directly — on native there is no containment to offer, and in the selected child document
|
|
63
|
+
// framing again would recurse into another, identical child document forever.
|
|
64
|
+
if (decision.mode === 'inline') return <>{children}</>;
|
|
65
|
+
|
|
66
|
+
// An invalid bound (not finite, zero, negative — a measurement that hasn't settled yet, a
|
|
67
|
+
// miscomputed layout value, an author typo) must never reach the iframe below: an honest
|
|
68
|
+
// unavailable state, not a frame opened at some browser-decided fallback size.
|
|
69
|
+
if (!isFiniteOverlaySize(width, height)) {
|
|
70
|
+
return (
|
|
71
|
+
<View style={styles.stage}>
|
|
72
|
+
<Text style={styles.status}>This example is unavailable right now.</Text>
|
|
73
|
+
</View>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<View style={[styles.stage, overlayStageDimensions(width, height)]}>
|
|
79
|
+
{status !== 'ready' && (
|
|
80
|
+
<Text style={styles.status}>{status === 'unavailable' ? 'This example is unavailable right now.' : 'Loading…'}</Text>
|
|
81
|
+
)}
|
|
82
|
+
{
|
|
83
|
+
// react-native-web has no `<Iframe>` primitive; this is the one place this component
|
|
84
|
+
// reaches past it for a genuine same-origin child document. `as unknown as string` only
|
|
85
|
+
// bypasses React Native's own JSX.IntrinsicElements typing (which never declares host-web
|
|
86
|
+
// tags) — the element itself, and every prop it's given, is still a plain, real iframe.
|
|
87
|
+
React.createElement('iframe' as unknown as string, {
|
|
88
|
+
src: decision.href,
|
|
89
|
+
title: `${pageId}: ${itemKey}`,
|
|
90
|
+
style: { ...StyleSheet.flatten(styles.frame), display: status === 'unavailable' ? 'none' : undefined },
|
|
91
|
+
onLoad: () => setStatus('ready'),
|
|
92
|
+
onError: () => setStatus('unavailable'),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
</View>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const styles = StyleSheet.create({
|
|
100
|
+
stage: {
|
|
101
|
+
backgroundColor: CATALOG_COLOR.surfacePressed,
|
|
102
|
+
alignSelf: 'center',
|
|
103
|
+
alignItems: 'center',
|
|
104
|
+
justifyContent: 'center',
|
|
105
|
+
},
|
|
106
|
+
frame: {
|
|
107
|
+
width: '100%',
|
|
108
|
+
height: '100%',
|
|
109
|
+
borderWidth: 0,
|
|
110
|
+
},
|
|
111
|
+
status: { position: 'absolute', fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
|
|
112
|
+
});
|
|
@@ -6,6 +6,8 @@ import { CatalogSidebar } from './CatalogSidebar';
|
|
|
6
6
|
import { SectionBlock } from './SectionBlock';
|
|
7
7
|
import { UpdatePanel } from './UpdatePanel';
|
|
8
8
|
import { DS_VIEWER_SECRET_HEADER, footerLink, groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner, UPDATE_PAGE_ID } from './catalogNavigation';
|
|
9
|
+
import { findOverlayNode, overlayAddressFromSearch } from './overlayViewport';
|
|
10
|
+
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
9
11
|
import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus } from './types';
|
|
10
12
|
|
|
11
13
|
/** `active` can be a real page id, or the reserved update-page id — never both a generic `TId`
|
|
@@ -142,6 +144,15 @@ export function CatalogShell<TId extends string>({
|
|
|
142
144
|
* **Update now** button when this is missing. */
|
|
143
145
|
updateEndpoint?: { baseUrl: string; secret: string };
|
|
144
146
|
}) {
|
|
147
|
+
// A `BoundedOverlayViewport`'s own child document (design §5): render ONLY the one authored
|
|
148
|
+
// node this document's URL addresses, with no sidebar/breadcrumb/title/pager — never the whole
|
|
149
|
+
// page, since that page's other examples (and this same example's own viewport wrapper, now
|
|
150
|
+
// recognizing itself as the selected address) would otherwise render alongside it for nothing.
|
|
151
|
+
// Read once per mount, like `active`'s own initial read below: this mode is only ever entered by
|
|
152
|
+
// a fresh document load, never a same-document transition.
|
|
153
|
+
const overlayAddress = useMemo(() => (isWeb() ? overlayAddressFromSearch(window.location.search) : undefined), []);
|
|
154
|
+
const overlayNode = useMemo(() => (overlayAddress ? findOverlayNode(sections, overlayAddress) : undefined), [overlayAddress, sections]);
|
|
155
|
+
|
|
145
156
|
const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
|
|
146
157
|
const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
|
|
147
158
|
const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys())), [groups, sectionsById]);
|
|
@@ -242,6 +253,17 @@ export function CatalogShell<TId extends string>({
|
|
|
242
253
|
// the approved mockup's own `frame()`: the banner only ever renders when `!isPanel`).
|
|
243
254
|
const showBanner = active !== UPDATE_PAGE_ID && shouldShowMajorBanner(effectiveUpdate, dismissedMajor);
|
|
244
255
|
|
|
256
|
+
if (overlayAddress) {
|
|
257
|
+
return (
|
|
258
|
+
<View style={styles.overlayDocumentRoot}>
|
|
259
|
+
{/* The addressed example's own device frame recognises this document as its viewport. */}
|
|
260
|
+
<SpecimenAddressProvider address={overlayAddress}>
|
|
261
|
+
{overlayNode ?? <Text style={styles.overlayUnavailable}>This example is no longer available.</Text>}
|
|
262
|
+
</SpecimenAddressProvider>
|
|
263
|
+
</View>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
245
267
|
return (
|
|
246
268
|
<View style={styles.root}>
|
|
247
269
|
<CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} />
|
|
@@ -284,6 +306,10 @@ export function CatalogShell<TId extends string>({
|
|
|
284
306
|
|
|
285
307
|
const styles = StyleSheet.create({
|
|
286
308
|
root: { flex: 1, flexDirection: 'row', backgroundColor: CATALOG_COLOR.pageBackground },
|
|
309
|
+
// A `BoundedOverlayViewport` child document's own root: no sidebar/chrome, just the one
|
|
310
|
+
// addressed node — sized by whatever that node itself renders, not the catalog's own layout.
|
|
311
|
+
overlayDocumentRoot: { flex: 1 },
|
|
312
|
+
overlayUnavailable: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted, padding: CATALOG_SPACE.lg },
|
|
287
313
|
main: { flex: 1 },
|
|
288
314
|
mainContent: {
|
|
289
315
|
width: '100%',
|
|
@@ -3,6 +3,8 @@ 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
5
|
import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
|
+
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
7
|
+
import { comparisonCellItemKey } from './overlayViewport';
|
|
6
8
|
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
7
9
|
import type { ComparisonDef, SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
8
10
|
|
|
@@ -65,8 +67,10 @@ export function ComparisonGrid({
|
|
|
65
67
|
const cell = cells.get(cellKey(row.key, c.key));
|
|
66
68
|
return (
|
|
67
69
|
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
|
-
<
|
|
70
|
+
<SpecimenSurface key={c.key} role="cell" surface={cell.surface ?? surface} style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
|
|
71
|
+
<SpecimenAddressProvider address={{ pageId: sectionId, slot: 'comparison', itemKey: comparisonCellItemKey(row.key, c.key) }}>
|
|
72
|
+
<SpecimenContent fill={cell.fill} align={cell.align}>{cell.node}</SpecimenContent>
|
|
73
|
+
</SpecimenAddressProvider>
|
|
70
74
|
</SpecimenSurface>
|
|
71
75
|
) : (
|
|
72
76
|
<View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
|
|
@@ -3,6 +3,7 @@ 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
5
|
import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
|
+
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
6
7
|
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
7
8
|
import type { SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
8
9
|
|
|
@@ -18,10 +19,13 @@ export function ComparisonGroups({
|
|
|
18
19
|
size,
|
|
19
20
|
label,
|
|
20
21
|
surface = DEFAULT_SPECIMEN_SURFACE,
|
|
22
|
+
pageId,
|
|
21
23
|
}: {
|
|
22
24
|
groups: ListGroup[];
|
|
23
25
|
size: SpecimenSize;
|
|
24
26
|
label: string;
|
|
27
|
+
/** The page these examples belong to, so a device frame inside one can address it. */
|
|
28
|
+
pageId?: string;
|
|
25
29
|
surface?: SpecimenSurfaceKind;
|
|
26
30
|
}) {
|
|
27
31
|
const columns = Math.max(1, ...groups.map((g) => g.items.length));
|
|
@@ -47,8 +51,10 @@ export function ComparisonGroups({
|
|
|
47
51
|
<View style={styles.caption}>
|
|
48
52
|
<Text style={styles.captionText}>{item.label}</Text>
|
|
49
53
|
</View>
|
|
50
|
-
<SpecimenSurface surface={surface} style={styles.specimen}>
|
|
51
|
-
<
|
|
54
|
+
<SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
|
|
55
|
+
<SpecimenAddressProvider address={pageId && item.slot ? { pageId, slot: item.slot, itemKey: item.key } : undefined}>
|
|
56
|
+
<SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
|
|
57
|
+
</SpecimenAddressProvider>
|
|
52
58
|
</SpecimenSurface>
|
|
53
59
|
</View>
|
|
54
60
|
);
|