@krapwoo/ds-viewer 0.4.9 → 0.6.0
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 +74 -8
- package/dist/cli/doctor.js +92 -5
- package/dist/cli/explain.js +11 -20
- package/dist/cli/main.js +11 -2
- package/dist/cli/render.js +338 -0
- package/dist/cli/staticPage.js +27 -4
- package/dist/native/catalog/comparison.js +69 -30
- package/dist/native/catalog/tokens.js +8 -6
- package/native/catalog/CatalogButton.tsx +47 -0
- package/native/catalog/CatalogShell.tsx +29 -11
- package/native/catalog/CatalogSidebar.tsx +18 -6
- package/native/catalog/ComparisonGrid.tsx +9 -8
- package/native/catalog/ComparisonGroups.tsx +6 -5
- package/native/catalog/ComparisonList.tsx +12 -3
- package/native/catalog/OverlayDemo.tsx +52 -0
- package/native/catalog/PhoneScreen.tsx +51 -0
- package/native/catalog/SectionBlock.tsx +16 -11
- package/native/catalog/SpecimenSurface.tsx +4 -4
- package/native/catalog/TokenLayouts.tsx +14 -3
- package/native/catalog/UpdatePanel.tsx +4 -4
- package/native/catalog/comparison.ts +77 -33
- package/native/catalog/index.ts +4 -1
- package/native/catalog/overlayDemoState.ts +8 -0
- package/native/catalog/pageApi.ts +3 -0
- package/native/catalog/specimenSurfaceStyle.ts +11 -0
- package/native/catalog/tokens.ts +8 -6
- package/native/catalog/types.ts +10 -3
- package/package.json +1 -1
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +38 -48
- package/starter-kit/components/Dialog/Dialog.catalog.tsx +16 -16
- package/starter-kit/components/Toast/Toast.catalog.tsx +9 -10
- package/starter-kit/pages/ReportIssue.catalog.tsx +17 -13
- package/starter-kit/pages/SavedTrips.catalog.tsx +114 -119
|
@@ -52,7 +52,7 @@ function resetMainScroll(scrollView: ScrollView | null): void {
|
|
|
52
52
|
* with only `onPress` (as `CatalogSidebar.tsx`'s own finding already documents) never activates on
|
|
53
53
|
* Enter under react-native-web unless the element is a genuine anchor. `role="status"` (not
|
|
54
54
|
* `"alert"`, the mockup's own choice) — a non-urgent notice should not interrupt assistive tech. */
|
|
55
|
-
function MajorBanner({ update, onReviewUpdate, onDismiss }: { update: UpdateNotice; onReviewUpdate: () => void; onDismiss: () => void }) {
|
|
55
|
+
function MajorBanner({ update, onReviewUpdate, onDismiss, keepDocumentUrl = false }: { update: UpdateNotice; onReviewUpdate: () => void; onDismiss: () => void; keepDocumentUrl?: boolean }) {
|
|
56
56
|
const releaseUrl = `https://github.com/krapwoo/design-system-viewer/releases/tag/v${update.latest}`;
|
|
57
57
|
return (
|
|
58
58
|
<View style={bannerStyles.banner} role="status">
|
|
@@ -68,7 +68,16 @@ function MajorBanner({ update, onReviewUpdate, onDismiss }: { update: UpdateNoti
|
|
|
68
68
|
Migration guide
|
|
69
69
|
</Text>
|
|
70
70
|
{' · '}
|
|
71
|
-
<Text
|
|
71
|
+
<Text
|
|
72
|
+
role="link"
|
|
73
|
+
style={bannerStyles.link}
|
|
74
|
+
onPress={(event?: { preventDefault?: () => void }) => {
|
|
75
|
+
// An embedded shell opens its own update page; the host page's address stays put.
|
|
76
|
+
if (keepDocumentUrl) event?.preventDefault?.();
|
|
77
|
+
onReviewUpdate();
|
|
78
|
+
}}
|
|
79
|
+
{...({ href: hashForId(UPDATE_PAGE_ID) } as Record<string, unknown>)}
|
|
80
|
+
>
|
|
72
81
|
Review update
|
|
73
82
|
</Text>
|
|
74
83
|
</Text>
|
|
@@ -96,7 +105,7 @@ const bannerStyles = StyleSheet.create({
|
|
|
96
105
|
bold: { fontWeight: '700' },
|
|
97
106
|
link: { color: CATALOG_COLOR.warning, fontWeight: '700', textDecorationLine: 'underline' },
|
|
98
107
|
close: { width: 32, height: 32, borderRadius: CATALOG_RADIUS.sm, alignItems: 'center', justifyContent: 'center' },
|
|
99
|
-
closeGlyph: { fontSize:
|
|
108
|
+
closeGlyph: { fontSize: CATALOG_TYPE.xl, color: CATALOG_COLOR.warning },
|
|
100
109
|
});
|
|
101
110
|
|
|
102
111
|
/**
|
|
@@ -115,9 +124,16 @@ export function CatalogShell<TId extends string>({
|
|
|
115
124
|
update,
|
|
116
125
|
versionStatus,
|
|
117
126
|
updateEndpoint,
|
|
127
|
+
embedded = false,
|
|
128
|
+
initialPage,
|
|
118
129
|
}: {
|
|
119
130
|
/** Short product/app name — the sidebar logo and the breadcrumb root. */
|
|
120
131
|
appName: string;
|
|
132
|
+
/** A shell shown inside another page (a catalog demo): it keeps its selected page in its own
|
|
133
|
+
* state, never reads or writes the URL, and doesn't treat the page's address as its own. */
|
|
134
|
+
embedded?: boolean;
|
|
135
|
+
/** The page an embedded shell opens on (defaults to the first page). */
|
|
136
|
+
initialPage?: TId;
|
|
121
137
|
/** Optional logo image, validated and bundled by `cli/workspace.ts` from the project's `logo`
|
|
122
138
|
* config field. See `CatalogSidebar`'s own doc comment for the approved layout rule. */
|
|
123
139
|
logoImageSource?: ImageSourcePropType;
|
|
@@ -150,14 +166,16 @@ export function CatalogShell<TId extends string>({
|
|
|
150
166
|
// recognizing itself as the selected address) would otherwise render alongside it for nothing.
|
|
151
167
|
// Read once per mount, like `active`'s own initial read below: this mode is only ever entered by
|
|
152
168
|
// a fresh document load, never a same-document transition.
|
|
153
|
-
|
|
169
|
+
// An embedded shell never owns the URL (hash routing, the overlay child-document address).
|
|
170
|
+
const routesByUrl = isWeb() && !embedded;
|
|
171
|
+
const overlayAddress = useMemo(() => (routesByUrl ? overlayAddressFromSearch(window.location.search) : undefined), [routesByUrl]);
|
|
154
172
|
const overlayNode = useMemo(() => (overlayAddress ? findOverlayNode(sections, overlayAddress) : undefined), [overlayAddress, sections]);
|
|
155
173
|
|
|
156
174
|
const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
|
|
157
175
|
const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
|
|
158
176
|
const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys())), [groups, sectionsById]);
|
|
159
177
|
const [active, setActive] = useState<ShellPageId<TId> | undefined>(() =>
|
|
160
|
-
isWeb() ? resolveActiveFromHash(window.location.hash, order) : order[0],
|
|
178
|
+
embedded ? initialPage ?? order[0] : isWeb() ? resolveActiveFromHash(window.location.hash, order) : order[0],
|
|
161
179
|
);
|
|
162
180
|
|
|
163
181
|
const scrollRef = useRef<ScrollView>(null);
|
|
@@ -179,7 +197,7 @@ export function CatalogShell<TId extends string>({
|
|
|
179
197
|
|
|
180
198
|
// Back/forward and manual fragment edits.
|
|
181
199
|
useEffect(() => {
|
|
182
|
-
if (!
|
|
200
|
+
if (!routesByUrl) return;
|
|
183
201
|
const onHashChange = () => {
|
|
184
202
|
const id = resolveActiveFromHash(window.location.hash, order);
|
|
185
203
|
if (id === undefined) return;
|
|
@@ -193,7 +211,7 @@ export function CatalogShell<TId extends string>({
|
|
|
193
211
|
};
|
|
194
212
|
window.addEventListener('hashchange', onHashChange);
|
|
195
213
|
return () => window.removeEventListener('hashchange', onHashChange);
|
|
196
|
-
}, [order]);
|
|
214
|
+
}, [order, routesByUrl]);
|
|
197
215
|
|
|
198
216
|
// After every page change: sync the fragment, return to the top, and move focus when the user
|
|
199
217
|
// navigated (never on first load).
|
|
@@ -203,7 +221,7 @@ export function CatalogShell<TId extends string>({
|
|
|
203
221
|
const focus = focusHeadingNext.current;
|
|
204
222
|
pushHistoryNext.current = false;
|
|
205
223
|
focusHeadingNext.current = false;
|
|
206
|
-
if (
|
|
224
|
+
if (routesByUrl) {
|
|
207
225
|
const expected = hashForId(active);
|
|
208
226
|
if (window.location.hash !== expected) {
|
|
209
227
|
if (push) window.location.hash = expected;
|
|
@@ -212,7 +230,7 @@ export function CatalogShell<TId extends string>({
|
|
|
212
230
|
}
|
|
213
231
|
resetMainScroll(scrollRef.current);
|
|
214
232
|
if (focus) focusElement(headingRef.current);
|
|
215
|
-
}, [active]);
|
|
233
|
+
}, [active, routesByUrl]);
|
|
216
234
|
|
|
217
235
|
const activeDef = active !== undefined && active !== UPDATE_PAGE_ID ? sectionsById.get(active) : undefined;
|
|
218
236
|
|
|
@@ -266,7 +284,7 @@ export function CatalogShell<TId extends string>({
|
|
|
266
284
|
|
|
267
285
|
return (
|
|
268
286
|
<View style={styles.root}>
|
|
269
|
-
<CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} />
|
|
287
|
+
<CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} keepDocumentUrl={embedded} />
|
|
270
288
|
<ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
|
|
271
289
|
{active === UPDATE_PAGE_ID ? (
|
|
272
290
|
<UpdatePanel
|
|
@@ -286,7 +304,7 @@ export function CatalogShell<TId extends string>({
|
|
|
286
304
|
// The approved mockup's own `PAGE_BG` order: crumb, then banner, then title — the
|
|
287
305
|
// banner used to render above the breadcrumb instead (Important finding, Fable
|
|
288
306
|
// correction pass).
|
|
289
|
-
banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} /> : undefined}
|
|
307
|
+
banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} keepDocumentUrl={embedded} /> : undefined}
|
|
290
308
|
pager={{
|
|
291
309
|
previousId: neighbors(order, activeDef.id).previous,
|
|
292
310
|
nextId: neighbors(order, activeDef.id).next,
|
|
@@ -13,12 +13,21 @@ 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
|
-
|
|
16
|
+
/** In an embedded shell, a link's own navigation would change the host page's address; the shell
|
|
17
|
+
* handles the press itself, so the browser's default is cancelled. */
|
|
18
|
+
function pressHandler(onPress: () => void, keepDocumentUrl: boolean) {
|
|
19
|
+
return (event?: { preventDefault?: () => void }) => {
|
|
20
|
+
if (keepDocumentUrl) event?.preventDefault?.();
|
|
21
|
+
onPress();
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function NavItem<TId extends string>({ id, label, active, onPress, keepDocumentUrl = false }: { id: TId; label: string; active: boolean; onPress: () => void; keepDocumentUrl?: boolean }) {
|
|
17
26
|
const [hovered, setHovered] = useState(false);
|
|
18
27
|
const [focused, setFocused] = useState(false);
|
|
19
28
|
return (
|
|
20
29
|
<Pressable
|
|
21
|
-
onPress={onPress}
|
|
30
|
+
onPress={pressHandler(onPress, keepDocumentUrl)}
|
|
22
31
|
onHoverIn={() => setHovered(true)}
|
|
23
32
|
onHoverOut={() => setHovered(false)}
|
|
24
33
|
onFocus={() => setFocused(true)}
|
|
@@ -43,12 +52,12 @@ function NavItem<TId extends string>({ id, label, active, onPress }: { id: TId;
|
|
|
43
52
|
);
|
|
44
53
|
}
|
|
45
54
|
|
|
46
|
-
function FooterLink({ label, kind, active, onPress }: { label: string; kind: 'update' | 'version'; active: boolean; onPress: () => void }) {
|
|
55
|
+
function FooterLink({ label, kind, active, onPress, keepDocumentUrl = false }: { label: string; kind: 'update' | 'version'; active: boolean; onPress: () => void; keepDocumentUrl?: boolean }) {
|
|
47
56
|
const [hovered, setHovered] = useState(false);
|
|
48
57
|
const [focused, setFocused] = useState(false);
|
|
49
58
|
return (
|
|
50
59
|
<Pressable
|
|
51
|
-
onPress={onPress}
|
|
60
|
+
onPress={pressHandler(onPress, keepDocumentUrl)}
|
|
52
61
|
onHoverIn={() => setHovered(true)}
|
|
53
62
|
onHoverOut={() => setHovered(false)}
|
|
54
63
|
onFocus={() => setFocused(true)}
|
|
@@ -84,8 +93,11 @@ export function CatalogSidebar<TId extends string>({
|
|
|
84
93
|
active,
|
|
85
94
|
onPress,
|
|
86
95
|
footer,
|
|
96
|
+
keepDocumentUrl = false,
|
|
87
97
|
}: {
|
|
88
98
|
logo: string;
|
|
99
|
+
/** For a shell embedded in another page: links never change the document's address. */
|
|
100
|
+
keepDocumentUrl?: boolean;
|
|
89
101
|
/** Optional logo image, validated and bundled by `cli/workspace.ts` from the project's `logo`
|
|
90
102
|
* config field. Visual spec: docs/design/2026-10-08-ds-viewer-sidebar-logo-approved.html
|
|
91
103
|
* ("C · Adaptive", approved 2026-10-08) — a mark (aspect ratio ≤ 2) sits beside the name; a
|
|
@@ -195,14 +207,14 @@ export function CatalogSidebar<TId extends string>({
|
|
|
195
207
|
<View key={group.label}>
|
|
196
208
|
<Text style={styles.groupLabel}>{group.label}</Text>
|
|
197
209
|
{group.ids.map((id) => (
|
|
198
|
-
<NavItem key={id} id={id} label={labels?.get(id) ?? id} active={active === id} onPress={() => onPress(id)} />
|
|
210
|
+
<NavItem key={id} id={id} label={labels?.get(id) ?? id} active={active === id} onPress={() => onPress(id)} keepDocumentUrl={keepDocumentUrl} />
|
|
199
211
|
))}
|
|
200
212
|
</View>
|
|
201
213
|
))}
|
|
202
214
|
</ScrollView>
|
|
203
215
|
{footer && (
|
|
204
216
|
<View style={styles.footerContainer}>
|
|
205
|
-
<FooterLink label={footer.label} kind={footer.kind ?? 'update'} active={footer.active} onPress={footer.onPress} />
|
|
217
|
+
<FooterLink label={footer.label} kind={footer.kind ?? 'update'} active={footer.active} onPress={footer.onPress} keepDocumentUrl={keepDocumentUrl} />
|
|
206
218
|
</View>
|
|
207
219
|
)}
|
|
208
220
|
</View>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { View, Text, ScrollView, StyleSheet } from 'react-native';
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
|
|
4
|
-
import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, axisItems, cellKey,
|
|
4
|
+
import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, axisItems, cellKey, gridMinWidth, indexCells, validateComparison } from './comparison';
|
|
5
5
|
import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
6
|
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
7
7
|
import { comparisonCellItemKey } from './overlayViewport';
|
|
@@ -10,9 +10,11 @@ import type { ComparisonDef, SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* A grid of two props that combine freely, rendered as an accessible table inside one card.
|
|
13
|
-
*
|
|
14
|
-
* a grid
|
|
15
|
-
*
|
|
13
|
+
* The card fills its container and its columns share that width equally, never shrinking below the
|
|
14
|
+
* specimen size's minimum; a grid narrower than that scrolls horizontally inside the card, never
|
|
15
|
+
* the page. Phone width (402px) caps the specimen inside a cell, not the column, so a two-column
|
|
16
|
+
* grid is as wide as a five-column one. `surface` is the page's `specimenSurface` behind each
|
|
17
|
+
* cell's example.
|
|
16
18
|
*/
|
|
17
19
|
export function ComparisonGrid({
|
|
18
20
|
def,
|
|
@@ -31,13 +33,12 @@ export function ComparisonGrid({
|
|
|
31
33
|
const cells = indexCells(def);
|
|
32
34
|
const rows = axisItems(def.rows);
|
|
33
35
|
const columns = axisItems(def.columns);
|
|
34
|
-
const
|
|
36
|
+
const minWidth = gridMinWidth(columns.length, size);
|
|
35
37
|
const column = {
|
|
36
38
|
flexGrow: 1,
|
|
37
39
|
flexShrink: 1,
|
|
38
40
|
flexBasis: COLUMN_MIN_WIDTH[size],
|
|
39
41
|
minWidth: COLUMN_MIN_WIDTH[size],
|
|
40
|
-
maxWidth: MATRIX_LAYOUT.columnMaxWidth,
|
|
41
42
|
};
|
|
42
43
|
const lastColumn = columns.length - 1;
|
|
43
44
|
|
|
@@ -46,7 +47,7 @@ export function ComparisonGrid({
|
|
|
46
47
|
<View
|
|
47
48
|
role="table"
|
|
48
49
|
aria-label={`${sectionId}: ${def.rowLabel} by ${def.columnLabel}`}
|
|
49
|
-
style={[styles.card, { minWidth
|
|
50
|
+
style={[styles.card, { minWidth }]}
|
|
50
51
|
>
|
|
51
52
|
<View role="row" style={styles.row}>
|
|
52
53
|
<View role="columnheader" style={[styles.cell, styles.rowHeader, styles.headerCell]}>
|
|
@@ -69,7 +70,7 @@ export function ComparisonGrid({
|
|
|
69
70
|
cell?.node !== undefined ? (
|
|
70
71
|
<SpecimenSurface key={c.key} role="cell" surface={cell.surface ?? surface} style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
|
|
71
72
|
<SpecimenAddressProvider address={{ pageId: sectionId, slot: 'comparison', itemKey: comparisonCellItemKey(row.key, c.key) }}>
|
|
72
|
-
<SpecimenContent fill={cell.fill} align={cell.align}>{cell.node}</SpecimenContent>
|
|
73
|
+
<SpecimenContent fill={cell.fill} align={cell.align} capped={size !== 'page'}>{cell.node}</SpecimenContent>
|
|
73
74
|
</SpecimenAddressProvider>
|
|
74
75
|
</SpecimenSurface>
|
|
75
76
|
) : (
|
|
@@ -10,9 +10,10 @@ import type { SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
|
10
10
|
/**
|
|
11
11
|
* Grouped rows: one row per variant, headed by the variant's name, holding that variant's own
|
|
12
12
|
* configurations. Each cell keeps its own caption because the rows do not share column meanings
|
|
13
|
-
* (a circle's size is not a bar's thickness). Each row is its own labeled list.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* (a circle's size is not a bar's thickness). Each row is its own labeled list. Cells share the
|
|
14
|
+
* card's width equally, the way a grid's columns do, and shorter rows end in blank cells; a card
|
|
15
|
+
* narrower than its minimum scrolls horizontally inside itself. `surface` is the page's
|
|
16
|
+
* `specimenSurface` behind each item.
|
|
16
17
|
*/
|
|
17
18
|
export function ComparisonGroups({
|
|
18
19
|
groups,
|
|
@@ -53,7 +54,7 @@ export function ComparisonGroups({
|
|
|
53
54
|
</View>
|
|
54
55
|
<SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
|
|
55
56
|
<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
|
+
<SpecimenContent fill={item.fill} align={item.align} capped={size !== 'page'}>{item.node as React.ReactNode}</SpecimenContent>
|
|
57
58
|
</SpecimenAddressProvider>
|
|
58
59
|
</SpecimenSurface>
|
|
59
60
|
</View>
|
|
@@ -91,7 +92,7 @@ const styles = StyleSheet.create({
|
|
|
91
92
|
},
|
|
92
93
|
rowHeaderText: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
|
|
93
94
|
cells: { flex: 1, flexDirection: 'row' },
|
|
94
|
-
cell: { flex: 1,
|
|
95
|
+
cell: { flex: 1, borderRightWidth: 1, borderRightColor: CATALOG_COLOR.border },
|
|
95
96
|
lastColumn: { borderRightWidth: 0 },
|
|
96
97
|
caption: {
|
|
97
98
|
padding: MATRIX_LAYOUT.cellPadding,
|
|
@@ -10,7 +10,7 @@ import type { SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
|
10
10
|
/**
|
|
11
11
|
* One-axis examples inside ONE shared card. Cells wrap into balanced rows (listGeometry); each cell
|
|
12
12
|
* has its own caption strip so labels stay attached when rows wrap. Blank cells complete an uneven
|
|
13
|
-
* last row. Wide specimens
|
|
13
|
+
* last row. Wide specimens keep 402px as a minimum and share the container above it. `maxColumns` caps
|
|
14
14
|
* how many columns a row ever wraps into (from `VariantSlot.maxColumns`); `surface` is the page's
|
|
15
15
|
* `specimenSurface` behind each item.
|
|
16
16
|
*/
|
|
@@ -42,7 +42,7 @@ export function ComparisonList({
|
|
|
42
42
|
for (let r = 0; r < geometry.rows; r += 1) rows.push(slots.slice(r * geometry.columns, (r + 1) * geometry.columns));
|
|
43
43
|
|
|
44
44
|
return (
|
|
45
|
-
<View onLayout={onLayout}>
|
|
45
|
+
<View onLayout={onLayout} style={styles.measure}>
|
|
46
46
|
<View role="list" aria-label={label} style={[styles.card, { width: geometry.containerWidth }]}>
|
|
47
47
|
{rows.map((row, ri) => (
|
|
48
48
|
<View key={ri} role="none" style={[styles.row, ri === rows.length - 1 && styles.lastRow]}>
|
|
@@ -67,7 +67,7 @@ export function ComparisonList({
|
|
|
67
67
|
</View>
|
|
68
68
|
<SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
|
|
69
69
|
<SpecimenAddressProvider address={pageId && item.slot ? { pageId, slot: item.slot, itemKey: item.key } : undefined}>
|
|
70
|
-
<SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
|
|
70
|
+
<SpecimenContent fill={item.fill} align={item.align} capped={size !== 'page'}>{item.node as React.ReactNode}</SpecimenContent>
|
|
71
71
|
</SpecimenAddressProvider>
|
|
72
72
|
</SpecimenSurface>
|
|
73
73
|
</View>
|
|
@@ -81,7 +81,16 @@ export function ComparisonList({
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
const styles = StyleSheet.create({
|
|
84
|
+
// The wrapper that measures the space the list has. It must stretch: left to hug its own card it
|
|
85
|
+
// would keep reporting the card's width, so a list inside a narrower container (a specimen cell)
|
|
86
|
+
// would never learn to shrink and would overflow. `alignItems` keeps the card centred, the
|
|
87
|
+
// position it had when the wrapper hugged it.
|
|
88
|
+
measure: { alignSelf: 'stretch', alignItems: 'center' },
|
|
84
89
|
card: {
|
|
90
|
+
// The width below is computed from the last measured space; until the first measurement lands
|
|
91
|
+
// it is a laptop-sized guess, so the card is also clamped to whatever space it really has —
|
|
92
|
+
// a list has no scroller of its own, and a too-wide guess would be cut off for a frame.
|
|
93
|
+
maxWidth: '100%',
|
|
85
94
|
backgroundColor: CATALOG_COLOR.surface,
|
|
86
95
|
borderWidth: 1,
|
|
87
96
|
borderColor: CATALOG_COLOR.borderStrong,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { StyleSheet, View } from 'react-native';
|
|
3
|
+
import { PhoneFrame } from './PhoneFrame';
|
|
4
|
+
import { CatalogButton } from './CatalogButton';
|
|
5
|
+
import { overlayTrigger } from './overlayDemoState';
|
|
6
|
+
|
|
7
|
+
// Not `StyleSheet.absoluteFill`: React Native 0.81's types reject spreading it.
|
|
8
|
+
const FILL = { position: 'absolute' as const, top: 0, right: 0, bottom: 0, left: 0 };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A phone with a catalog button that opens an overlay: a sheet, a dialog, a toast. The overlay
|
|
12
|
+
* (`children`) fills the device above the centred trigger and gets `{ open, close }`; keep it
|
|
13
|
+
* mounted and drive its own `visible` from `open`, so its open and close animations play.
|
|
14
|
+
*/
|
|
15
|
+
export function OverlayDemo({
|
|
16
|
+
triggerLabel,
|
|
17
|
+
initiallyOpen = false,
|
|
18
|
+
keepTrigger = false,
|
|
19
|
+
children,
|
|
20
|
+
}: {
|
|
21
|
+
triggerLabel: string | ((open: boolean) => string);
|
|
22
|
+
initiallyOpen?: boolean;
|
|
23
|
+
/** Keep the trigger visible while open, as a toggle. @default false */
|
|
24
|
+
keepTrigger?: boolean;
|
|
25
|
+
children: (state: { open: boolean; close: () => void }) => React.ReactNode;
|
|
26
|
+
}) {
|
|
27
|
+
const [open, setOpen] = useState(initiallyOpen);
|
|
28
|
+
const trigger = overlayTrigger(open, { triggerLabel, keepTrigger });
|
|
29
|
+
// When the overlay closes, keyboard focus returns to the trigger instead of the page body.
|
|
30
|
+
const triggerRef = useRef<View>(null);
|
|
31
|
+
const wasOpen = useRef(open);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (wasOpen.current && !open) (triggerRef.current as unknown as { focus?: () => void } | null)?.focus?.();
|
|
34
|
+
wasOpen.current = open;
|
|
35
|
+
}, [open]);
|
|
36
|
+
return (
|
|
37
|
+
<PhoneFrame>
|
|
38
|
+
<View style={styles.screen}>
|
|
39
|
+
{trigger.visible && (
|
|
40
|
+
<CatalogButton ref={triggerRef} label={trigger.label} onPress={() => setOpen((value) => (keepTrigger ? !value : true))} />
|
|
41
|
+
)}
|
|
42
|
+
<View style={FILL} pointerEvents="box-none">
|
|
43
|
+
{children({ open, close: () => setOpen(false) })}
|
|
44
|
+
</View>
|
|
45
|
+
</View>
|
|
46
|
+
</PhoneFrame>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const styles = StyleSheet.create({
|
|
51
|
+
screen: { flex: 1, alignSelf: 'stretch', alignItems: 'center', justifyContent: 'center' },
|
|
52
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ScrollView, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A whole app screen inside a `PhoneFrame`: `header` pinned to the top, `footer` to the bottom,
|
|
6
|
+
* and the children in a body between them that scrolls instead of pushing the footer off the
|
|
7
|
+
* device. `floating` is a layer over the top of the screen for toasts; it adds no inset of its
|
|
8
|
+
* own, so the app positions its content. `bodyWrapper` lets the app put its own surface or
|
|
9
|
+
* context provider around the scrolling body; give its root `flex: 1, minHeight: 0`.
|
|
10
|
+
*/
|
|
11
|
+
export function PhoneScreen({
|
|
12
|
+
header,
|
|
13
|
+
footer,
|
|
14
|
+
floating,
|
|
15
|
+
bodyWrapper,
|
|
16
|
+
scroll = true,
|
|
17
|
+
contentContainerStyle,
|
|
18
|
+
children,
|
|
19
|
+
}: {
|
|
20
|
+
header?: React.ReactNode;
|
|
21
|
+
footer?: React.ReactNode;
|
|
22
|
+
floating?: React.ReactNode;
|
|
23
|
+
bodyWrapper?: (body: React.ReactNode) => React.ReactNode;
|
|
24
|
+
/** Scroll the body (default), or lay it out to fit. */
|
|
25
|
+
scroll?: boolean;
|
|
26
|
+
contentContainerStyle?: StyleProp<ViewStyle>;
|
|
27
|
+
children?: React.ReactNode;
|
|
28
|
+
}) {
|
|
29
|
+
const body = scroll ? (
|
|
30
|
+
<ScrollView style={styles.flex} contentContainerStyle={contentContainerStyle}>{children}</ScrollView>
|
|
31
|
+
) : (
|
|
32
|
+
<View style={[styles.flex, contentContainerStyle]}>{children}</View>
|
|
33
|
+
);
|
|
34
|
+
return (
|
|
35
|
+
<View style={styles.screen}>
|
|
36
|
+
{header}
|
|
37
|
+
<View style={styles.flex}>{bodyWrapper ? bodyWrapper(body) : body}</View>
|
|
38
|
+
{footer}
|
|
39
|
+
{floating ? (
|
|
40
|
+
<View style={styles.floating} pointerEvents="box-none">{floating}</View>
|
|
41
|
+
) : null}
|
|
42
|
+
</View>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const styles = StyleSheet.create({
|
|
47
|
+
// Fills the device; `minHeight: 0` lets a flex child shrink below its content on the web.
|
|
48
|
+
screen: { flex: 1, alignSelf: 'stretch', minHeight: 0 },
|
|
49
|
+
flex: { flex: 1, minHeight: 0 },
|
|
50
|
+
floating: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 20 },
|
|
51
|
+
});
|
|
@@ -9,7 +9,7 @@ import { SpecimenSurface } from './SpecimenSurface';
|
|
|
9
9
|
import { SpecimenAddressProvider } from './SpecimenAddress';
|
|
10
10
|
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
11
11
|
import { TokenSections } from './TokenLayouts';
|
|
12
|
-
import { MATRIX_LAYOUT,
|
|
12
|
+
import { MATRIX_LAYOUT, PREVIEW_CARD_CHROME, blockMinWidth, choosePlacement, presentationBlocks, type PlacementBlock, type PresentationBlock } from './comparison';
|
|
13
13
|
import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
|
|
14
14
|
|
|
15
15
|
// Matches a quoted-string-literal union type, e.g. "'primary' | 'secondary' | 'tertiary'" — anything
|
|
@@ -184,7 +184,7 @@ function BlockContent<TId extends string>({ block, def, surface }: { block: Pres
|
|
|
184
184
|
</SpecimenAddressProvider>
|
|
185
185
|
) : null;
|
|
186
186
|
case 'tokenSections':
|
|
187
|
-
return <TokenSections sections={block.sections} columns={block.columns} />;
|
|
187
|
+
return <TokenSections sections={block.sections} columns={block.columns} label={def.id} />;
|
|
188
188
|
default:
|
|
189
189
|
return (
|
|
190
190
|
<View style={styles.previewCard}>
|
|
@@ -195,8 +195,13 @@ function BlockContent<TId extends string>({ block, def, surface }: { block: Pres
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
/** Width a block's card occupies: lists hug their columns; everything else fills the row. */
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
/** What `choosePlacement` needs about a block: how narrow it can get, and (for a list) the geometry
|
|
201
|
+
* that predicts its height in half a row. */
|
|
202
|
+
function placementBlock(block: PresentationBlock, height: number): PlacementBlock {
|
|
203
|
+
const base = { kind: block.kind, height, minWidth: blockMinWidth(block) };
|
|
204
|
+
return block.kind === 'list' ? { ...base, itemCount: block.items.length, size: block.size, maxColumns: block.maxColumns } : base;
|
|
200
205
|
}
|
|
201
206
|
|
|
202
207
|
/**
|
|
@@ -236,10 +241,8 @@ function Blocks<TId extends string>({ blocks, def, surface }: { blocks: Presenta
|
|
|
236
241
|
const decision = choosePlacement({
|
|
237
242
|
available,
|
|
238
243
|
gap: CATALOG_LAYOUT.blockGap,
|
|
239
|
-
first:
|
|
240
|
-
second: second
|
|
241
|
-
? { kind: 'list', height: heights[1], itemCount: second.items.length, size: second.size, maxColumns: second.maxColumns }
|
|
242
|
-
: { kind: second.kind, height: heights[1] },
|
|
244
|
+
first: placementBlock(first, heights[0]),
|
|
245
|
+
second: placementBlock(second, heights[1]),
|
|
243
246
|
});
|
|
244
247
|
if (decision === 'side') setPlacement('side');
|
|
245
248
|
}, [available, blocks, heights, placement]);
|
|
@@ -251,7 +254,8 @@ function Blocks<TId extends string>({ blocks, def, surface }: { blocks: Presenta
|
|
|
251
254
|
<View
|
|
252
255
|
key={block.title || block.kind}
|
|
253
256
|
onLayout={onBlockLayout(i)}
|
|
254
|
-
|
|
257
|
+
// Side by side, each block fills its own half: a card always fills the column it is given.
|
|
258
|
+
style={side ? styles.blockFill : undefined}
|
|
255
259
|
>
|
|
256
260
|
{block.title ? <Text style={styles.blockLabel}>{block.title}</Text> : null}
|
|
257
261
|
<BlockContent block={block} def={def} surface={surface} />
|
|
@@ -394,10 +398,11 @@ const styles = StyleSheet.create({
|
|
|
394
398
|
},
|
|
395
399
|
previewCard: {
|
|
396
400
|
backgroundColor: CATALOG_COLOR.surface,
|
|
397
|
-
|
|
401
|
+
// From comparison.ts, which adds the same numbers to a preview's minimum width.
|
|
402
|
+
borderWidth: PREVIEW_CARD_CHROME.border,
|
|
398
403
|
borderColor: CATALOG_COLOR.borderStrong,
|
|
399
404
|
borderRadius: CATALOG_RADIUS.card,
|
|
400
|
-
padding:
|
|
405
|
+
padding: PREVIEW_CARD_CHROME.padding,
|
|
401
406
|
gap: CATALOG_SPACE.md,
|
|
402
407
|
},
|
|
403
408
|
// `ComparisonList`'s own card/caption tokens, reused so a 'table' preview reads as the same shared
|
|
@@ -3,7 +3,7 @@ import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
|
|
3
3
|
import { CATALOG_COLOR } from './tokens';
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE, overflowsCell, paintedExtent, shouldCenterFilledContent, specimenContentAlignItems,
|
|
6
|
-
specimenContentBoundedStyle, visibleBoxes, type LayoutBox,
|
|
6
|
+
specimenContentBoundedStyle, visibleBoxes, filledContentStyle, type LayoutBox,
|
|
7
7
|
} from './specimenSurfaceStyle';
|
|
8
8
|
import { useSpecimenAddress } from './SpecimenAddress';
|
|
9
9
|
import { blendsIntoCell, measurePaintedBoxes } from './specimenFill';
|
|
@@ -115,7 +115,7 @@ function readTree(root: MeasurableNode, browser: Browser): LayoutBox {
|
|
|
115
115
|
return read(root);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
export function SpecimenContent({ fill, align, children }: { fill?: boolean; align?: SpecimenAlign; children: React.ReactNode }) {
|
|
118
|
+
export function SpecimenContent({ fill, align, capped = true, children }: { fill?: boolean; align?: SpecimenAlign; /** Phone-width cap for a filled example; off for page-width cells. */ capped?: boolean; children: React.ReactNode }) {
|
|
119
119
|
// After layout (browser only): a `fill` example whose visible content is narrower than the cell
|
|
120
120
|
// (a fixed-width skeleton, a tooltip around a small trigger) is centred instead of sitting
|
|
121
121
|
// against the leading edge; an explicit `align` always wins. And in development, an example
|
|
@@ -134,13 +134,13 @@ export function SpecimenContent({ fill, align, children }: { fill?: boolean; ali
|
|
|
134
134
|
if ((globalThis as { __DEV__?: boolean }).__DEV__ && !warned.current && overflowsCell(visible, wrapperWidth)) {
|
|
135
135
|
warned.current = true;
|
|
136
136
|
console.warn(
|
|
137
|
-
`[Catalog] ${address ? `${address.pageId} (${address.itemKey})` : 'An example'} is ${Math.round(visible)}px wide in a ${Math.round(wrapperWidth)}px cell, so it's cut off. Give the page a wider specimenSize ('regular' or '
|
|
137
|
+
`[Catalog] ${address ? `${address.pageId} (${address.itemKey})` : 'An example'} is ${Math.round(visible)}px wide in a ${Math.round(wrapperWidth)}px cell, so it's cut off. Give the page a wider specimenSize ('regular', 'wide', or 'page' for components wider than a phone).`,
|
|
138
138
|
);
|
|
139
139
|
}
|
|
140
140
|
}, [fill, align, centerNarrow, address]);
|
|
141
141
|
if (fill) {
|
|
142
142
|
const alignItems = centerNarrow ? 'center' : specimenContentAlignItems(fill, align);
|
|
143
|
-
return <View ref={ref} onLayout={measure} style={[SPECIMEN_SURFACE_WIDTH_STYLE, { alignItems }]}>{children}</View>;
|
|
143
|
+
return <View ref={ref} onLayout={measure} style={[SPECIMEN_SURFACE_WIDTH_STYLE, filledContentStyle(capped), { alignItems }]}>{children}</View>;
|
|
144
144
|
}
|
|
145
145
|
return <View ref={ref} onLayout={measure} style={specimenContentBoundedStyle(align)}>{children}</View>;
|
|
146
146
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useRef, useState } from 'react';
|
|
2
2
|
import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
|
|
4
|
-
import { sectionColumns, tileGeometry, TOKEN_LAYOUT_GAP } from './tokenLayout';
|
|
4
|
+
import { sectionColumns, tileGeometry, TOKEN_LAYOUT_GAP, TOKEN_SECTION_MIN_WIDTH } from './tokenLayout';
|
|
5
5
|
import type { TokenSection } from './types';
|
|
6
6
|
|
|
7
7
|
/** Measures its own width; renders nothing inside until the width is known. */
|
|
@@ -18,9 +18,20 @@ function useWidth(): [number, (event: LayoutChangeEvent) => void] {
|
|
|
18
18
|
* in up to `columns` columns. A `wide` section takes a full row; the page drops to fewer columns
|
|
19
19
|
* rather than squeeze a section under its minimum width.
|
|
20
20
|
*/
|
|
21
|
-
export function TokenSections({ sections, columns }: { sections: TokenSection[]; columns: 1 | 2 | 3 }) {
|
|
21
|
+
export function TokenSections({ sections, columns, label }: { sections: TokenSection[]; columns: 1 | 2 | 3; label?: string }) {
|
|
22
22
|
const [width, onLayout] = useWidth();
|
|
23
23
|
const perRow = sectionColumns(width, columns);
|
|
24
|
+
// Degrading quietly is how a page ends up claiming a column count it never renders: the author
|
|
25
|
+
// reads `columns={3}`, the reader sees one column, and nothing says the space was the reason.
|
|
26
|
+
// Once per transition, not once per render: a resize re-renders continuously.
|
|
27
|
+
const warned = useRef('');
|
|
28
|
+
if (width > 0 && perRow < columns && (globalThis as { __DEV__?: boolean }).__DEV__) {
|
|
29
|
+
const key = `${columns}:${perRow}`;
|
|
30
|
+
if (warned.current !== key) {
|
|
31
|
+
warned.current = key;
|
|
32
|
+
console.warn(`[Catalog] ${label ?? 'TokenSections'}: asked for ${columns} columns but only ${perRow} fit in ${width}px; sections never go below ${TOKEN_SECTION_MIN_WIDTH}px.`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
24
35
|
const columnWidth = perRow === 1 ? width : (width - TOKEN_LAYOUT_GAP * (perRow - 1)) / perRow;
|
|
25
36
|
return (
|
|
26
37
|
<View onLayout={onLayout} style={styles.sections}>
|
|
@@ -663,8 +663,8 @@ const styles = StyleSheet.create({
|
|
|
663
663
|
paddingVertical: 2,
|
|
664
664
|
},
|
|
665
665
|
versionsRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
|
666
|
-
versionsText: { fontSize:
|
|
667
|
-
arrow: { fontSize:
|
|
666
|
+
versionsText: { fontSize: CATALOG_TYPE['2xl'], fontWeight: '800', color: CATALOG_COLOR.text },
|
|
667
|
+
arrow: { fontSize: CATALOG_TYPE['2xl'], color: CATALOG_COLOR.textMuted },
|
|
668
668
|
pill: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, backgroundColor: CATALOG_COLOR.accentSubtle },
|
|
669
669
|
pillMajor: { backgroundColor: CATALOG_COLOR.warningSubtle },
|
|
670
670
|
pillSuccess: { backgroundColor: CATALOG_COLOR.successSubtle },
|
|
@@ -714,10 +714,10 @@ const styles = StyleSheet.create({
|
|
|
714
714
|
stepIconNow: { borderWidth: 2, borderColor: CATALOG_COLOR.accent },
|
|
715
715
|
stepIconTodo: { borderWidth: 2, borderColor: CATALOG_COLOR.borderStrong },
|
|
716
716
|
stepIconFail: { backgroundColor: CATALOG_COLOR.dangerSubtle },
|
|
717
|
-
stepIconGlyph: { fontSize:
|
|
717
|
+
stepIconGlyph: { fontSize: CATALOG_TYPE.sm, fontWeight: '800', color: CATALOG_COLOR.text },
|
|
718
718
|
stepLabel: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted },
|
|
719
719
|
stepLabelNow: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text, fontWeight: '700' },
|
|
720
720
|
stepLabelFail: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text, fontWeight: '700' },
|
|
721
721
|
logBox: { backgroundColor: '#1d1d1f', borderRadius: CATALOG_RADIUS.md, padding: 12, marginTop: 8, maxHeight: 240 },
|
|
722
|
-
logText: { color: '#e8e8e8', fontSize:
|
|
722
|
+
logText: { color: '#e8e8e8', fontSize: CATALOG_TYPE.sm, fontFamily: 'Menlo, monospace' },
|
|
723
723
|
});
|