@krapwoo/ds-viewer 0.1.0 → 0.2.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 +100 -78
- package/dist/cli/config.js +26 -1
- package/dist/cli/dev.js +50 -5
- package/dist/cli/glob.js +8 -0
- package/dist/cli/init.js +148 -18
- package/dist/cli/main.js +102 -3
- package/dist/cli/props.js +166 -18
- package/dist/cli/sync.js +17 -1
- package/dist/cli/workspace.js +49 -21
- package/native/catalog/CatalogShell.tsx +6 -1
- package/native/catalog/CatalogSidebar.tsx +98 -5
- package/native/catalog/TokenRow.tsx +3 -2
- package/native/catalog/index.ts +6 -5
- package/native/catalog/logoLayout.ts +12 -0
- package/native/catalog/pageApi.ts +10 -1
- package/native/catalog/tokens.ts +5 -0
- package/package.json +11 -7
- package/starter-kit/WHEN_TO_USE.md +124 -0
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +23 -0
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.tsx +46 -0
- package/starter-kit/components/AnimatedChevron/index.ts +2 -0
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +72 -0
- package/starter-kit/components/Avatar/Avatar.tsx +74 -0
- package/starter-kit/components/Avatar/index.ts +2 -0
- package/starter-kit/components/Badge/Badge.catalog.tsx +96 -0
- package/starter-kit/components/Badge/Badge.tsx +56 -0
- package/starter-kit/components/Badge/Badge.types.ts +18 -0
- package/starter-kit/components/Badge/index.ts +2 -0
- package/starter-kit/components/Banner/Banner.catalog.tsx +87 -0
- package/starter-kit/components/Banner/Banner.tsx +257 -0
- package/starter-kit/components/Banner/Banner.types.ts +38 -0
- package/starter-kit/components/Banner/index.ts +2 -0
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +77 -0
- package/starter-kit/components/BottomSheet/BottomSheet.tsx +151 -0
- package/starter-kit/components/BottomSheet/index.ts +2 -0
- package/starter-kit/components/Button/Button.catalog.tsx +113 -0
- package/starter-kit/components/Button/Button.tsx +173 -0
- package/starter-kit/components/Button/Button.types.ts +52 -0
- package/starter-kit/components/Button/index.ts +2 -0
- package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +39 -0
- package/starter-kit/components/ButtonGroup/ButtonGroup.tsx +84 -0
- package/starter-kit/components/ButtonGroup/index.ts +2 -0
- package/starter-kit/components/Card/Card.catalog.tsx +68 -0
- package/starter-kit/components/Card/Card.tsx +133 -0
- package/starter-kit/components/Card/index.ts +2 -0
- package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +28 -0
- package/starter-kit/components/Checkbox/Checkbox.tsx +104 -0
- package/starter-kit/components/Checkbox/index.ts +2 -0
- package/starter-kit/components/Dialog/Dialog.catalog.tsx +41 -0
- package/starter-kit/components/Dialog/Dialog.tsx +82 -0
- package/starter-kit/components/Dialog/index.ts +2 -0
- package/starter-kit/components/Divider/Divider.catalog.tsx +35 -0
- package/starter-kit/components/Divider/Divider.tsx +20 -0
- package/starter-kit/components/Divider/index.ts +2 -0
- package/starter-kit/components/Dock/Dock.catalog.tsx +71 -0
- package/starter-kit/components/Dock/Dock.tsx +58 -0
- package/starter-kit/components/Dock/index.ts +2 -0
- package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +69 -0
- package/starter-kit/components/Dropdown/Dropdown.tsx +162 -0
- package/starter-kit/components/Dropdown/index.ts +2 -0
- package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +67 -0
- package/starter-kit/components/EmptyState/EmptyState.tsx +88 -0
- package/starter-kit/components/EmptyState/index.ts +2 -0
- package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +76 -0
- package/starter-kit/components/FieldContainer/FieldContainer.tsx +99 -0
- package/starter-kit/components/FieldContainer/FieldContainer.types.ts +27 -0
- package/starter-kit/components/FieldContainer/index.ts +2 -0
- package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +18 -0
- package/starter-kit/components/InputClearButton/InputClearButton.tsx +63 -0
- package/starter-kit/components/InputClearButton/index.ts +2 -0
- package/starter-kit/components/InputField/InputField.catalog.tsx +76 -0
- package/starter-kit/components/InputField/InputField.tsx +418 -0
- package/starter-kit/components/InputField/index.ts +2 -0
- package/starter-kit/components/List/List.catalog.tsx +30 -0
- package/starter-kit/components/List/List.tsx +36 -0
- package/starter-kit/components/List/index.ts +2 -0
- package/starter-kit/components/ListItem/ListItem.catalog.tsx +89 -0
- package/starter-kit/components/ListItem/ListItem.tsx +165 -0
- package/starter-kit/components/ListItem/index.ts +2 -0
- package/starter-kit/components/Loading/Loading.catalog.tsx +34 -0
- package/starter-kit/components/Loading/Loading.tsx +121 -0
- package/starter-kit/components/Loading/index.ts +2 -0
- package/starter-kit/components/Pill/Pill.catalog.tsx +93 -0
- package/starter-kit/components/Pill/Pill.tsx +181 -0
- package/starter-kit/components/Pill/Pill.types.ts +30 -0
- package/starter-kit/components/Pill/index.ts +2 -0
- package/starter-kit/components/PillRow/PillRow.catalog.tsx +91 -0
- package/starter-kit/components/PillRow/PillRow.tsx +140 -0
- package/starter-kit/components/PillRow/PillRow.types.ts +24 -0
- package/starter-kit/components/PillRow/index.ts +2 -0
- package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +22 -0
- package/starter-kit/components/ProgressDots/ProgressDots.tsx +42 -0
- package/starter-kit/components/ProgressDots/index.ts +2 -0
- package/starter-kit/components/Radio/Radio.catalog.tsx +38 -0
- package/starter-kit/components/Radio/Radio.tsx +114 -0
- package/starter-kit/components/Radio/index.ts +2 -0
- package/starter-kit/components/Radio/usePressScale.ts +33 -0
- package/starter-kit/components/SearchField/SearchField.catalog.tsx +28 -0
- package/starter-kit/components/SearchField/SearchField.tsx +98 -0
- package/starter-kit/components/SearchField/index.ts +2 -0
- package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +72 -0
- package/starter-kit/components/SectionHeader/SectionHeader.tsx +139 -0
- package/starter-kit/components/SectionHeader/index.ts +2 -0
- package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +31 -0
- package/starter-kit/components/SegmentedToggle/SegmentedToggle.tsx +163 -0
- package/starter-kit/components/SegmentedToggle/index.ts +2 -0
- package/starter-kit/components/SegmentedToggle/useSlideAnim.ts +29 -0
- package/starter-kit/components/Shimmer/Breathing.catalog.tsx +29 -0
- package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +89 -0
- package/starter-kit/components/Shimmer/Shimmer.tsx +158 -0
- package/starter-kit/components/Shimmer/Shimmer.types.ts +27 -0
- package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +39 -0
- package/starter-kit/components/Shimmer/index.ts +2 -0
- package/starter-kit/components/Surface/Surface.catalog.tsx +44 -0
- package/starter-kit/components/Surface/Surface.tsx +45 -0
- package/starter-kit/components/Surface/index.ts +2 -0
- package/starter-kit/components/Switch/Switch.catalog.tsx +33 -0
- package/starter-kit/components/Switch/Switch.tsx +129 -0
- package/starter-kit/components/Switch/index.ts +2 -0
- package/starter-kit/components/TextArea/TextArea.catalog.tsx +29 -0
- package/starter-kit/components/TextArea/TextArea.tsx +99 -0
- package/starter-kit/components/TextArea/index.ts +2 -0
- package/starter-kit/components/Toast/Toast.catalog.tsx +61 -0
- package/starter-kit/components/Toast/Toast.tsx +144 -0
- package/starter-kit/components/Toast/index.ts +2 -0
- package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +121 -0
- package/starter-kit/components/Tooltip/Tooltip.tsx +195 -0
- package/starter-kit/components/Tooltip/index.ts +2 -0
- package/starter-kit/components/TopNav/TopNav.catalog.tsx +71 -0
- package/starter-kit/components/TopNav/TopNav.tsx +77 -0
- package/starter-kit/components/TopNav/index.ts +2 -0
- package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +30 -0
- package/starter-kit/components/UnderlineTabs/UnderlineTabs.tsx +156 -0
- package/starter-kit/components/UnderlineTabs/index.ts +2 -0
- package/starter-kit/components/index.ts +36 -0
- package/starter-kit/components/statusColors.ts +27 -0
- package/starter-kit/icons/Icon.native.tsx +90 -0
- package/starter-kit/icons/index.ts +10 -0
- package/starter-kit/icons/paths.ts +623 -0
- package/starter-kit/icons/types.ts +69 -0
- package/starter-kit/pages/Colors.catalog.tsx +55 -0
- package/starter-kit/pages/Font.catalog.tsx +45 -0
- package/starter-kit/pages/Icons.catalog.tsx +51 -0
- package/starter-kit/pages/Manifest.catalog.tsx +24 -0
- package/starter-kit/pages/Motion.catalog.tsx +101 -0
- package/starter-kit/pages/Radius.catalog.tsx +35 -0
- package/starter-kit/pages/ReportIssue.catalog.tsx +148 -0
- package/starter-kit/pages/SavedTrips.catalog.tsx +247 -0
- package/starter-kit/pages/Shadow.catalog.tsx +34 -0
- package/starter-kit/pages/Spacing.catalog.tsx +12 -0
- package/starter-kit/pages/TripPlannerForm.catalog.tsx +48 -0
- package/starter-kit/pages/Typography.catalog.tsx +17 -0
- package/starter-kit/tokens/index.ts +50 -0
- package/starter-kit/tokens/motion.ts +90 -0
- package/starter-kit/tokens/palette.ts +111 -0
- package/starter-kit/tokens/scales.ts +106 -0
- package/starter-kit/tokens/semantic.ts +98 -0
- package/starter-kit/tokens/shadow.ts +36 -0
- package/starter-kit/tokens/typography.ts +72 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
-
import { View, Text, ScrollView, Pressable, StyleSheet } from 'react-native';
|
|
3
|
-
import {
|
|
2
|
+
import { View, Text, ScrollView, Pressable, StyleSheet, Image } from 'react-native';
|
|
3
|
+
import type { ImageSourcePropType, LayoutChangeEvent, NativeSyntheticEvent, ImageLoadEventData } from 'react-native';
|
|
4
|
+
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
|
|
5
|
+
import { logoLayout } from './logoLayout';
|
|
4
6
|
import { filterGroups, hashForId } from './catalogNavigation';
|
|
5
7
|
import type { NavGroup } from './types';
|
|
6
8
|
import { CatalogSearchInput } from './CatalogSearchInput';
|
|
@@ -48,25 +50,108 @@ function NavItem<TId extends string>({ id, active, onPress }: { id: TId; active:
|
|
|
48
50
|
*/
|
|
49
51
|
export function CatalogSidebar<TId extends string>({
|
|
50
52
|
logo,
|
|
53
|
+
logoImageSource,
|
|
51
54
|
caption,
|
|
52
55
|
groups,
|
|
53
56
|
active,
|
|
54
57
|
onPress,
|
|
55
58
|
}: {
|
|
56
59
|
logo: string;
|
|
60
|
+
/** Optional logo image, validated and bundled by `cli/workspace.ts` from the project's `logo`
|
|
61
|
+
* config field. Visual spec: docs/design/2026-10-08-ds-viewer-sidebar-logo-approved.html
|
|
62
|
+
* ("C · Adaptive", approved 2026-10-08) — a mark (aspect ratio ≤ 2) sits beside the name; a
|
|
63
|
+
* wordmark (> 2) replaces the name text, carrying it as its own accessible label instead. */
|
|
64
|
+
logoImageSource?: ImageSourcePropType;
|
|
57
65
|
caption: string;
|
|
58
66
|
groups: NavGroup<TId>[];
|
|
59
67
|
active: TId | undefined;
|
|
60
68
|
onPress: (id: TId) => void;
|
|
61
69
|
}) {
|
|
62
70
|
const [query, setQuery] = useState('');
|
|
71
|
+
// A bundled local image (the only kind `cli/workspace.ts` ever produces — see its doc comment
|
|
72
|
+
// above) already carries its own `width`/`height` on the required module object, both on native
|
|
73
|
+
// and on react-native-web (confirmed by reading `AssetRegistry`/Metro's asset plugin output) — no
|
|
74
|
+
// need to wait for a load event for the common case. `onLoad` below is kept only as a fallback for
|
|
75
|
+
// a source with no static dimensions (e.g. a bare `{ uri }`); the mock prober stops rendering as
|
|
76
|
+
// soon as either resolves an aspect.
|
|
77
|
+
const staticSource = typeof logoImageSource === 'object' && logoImageSource !== null && !Array.isArray(logoImageSource)
|
|
78
|
+
? (logoImageSource as { width?: number; height?: number })
|
|
79
|
+
: undefined;
|
|
80
|
+
const staticAspect = staticSource?.width && staticSource?.height ? staticSource.width / staticSource.height : undefined;
|
|
81
|
+
const [loadedAspect, setLoadedAspect] = useState<number | undefined>(undefined);
|
|
82
|
+
const [logoFailed, setLogoFailed] = useState(false);
|
|
83
|
+
const [nameWrapped, setNameWrapped] = useState(false);
|
|
63
84
|
const filtered = filterGroups(groups, query);
|
|
64
85
|
|
|
86
|
+
const logoAspect = staticAspect ?? loadedAspect;
|
|
87
|
+
const attemptingLogo = Boolean(logoImageSource) && !logoFailed;
|
|
88
|
+
const header = attemptingLogo ? logoLayout(logoAspect) : 'text';
|
|
89
|
+
|
|
90
|
+
// Fallback only (see the comment above `staticSource`) — and native-only in practice: on web,
|
|
91
|
+
// `ImageLoader.load`'s `onLoad` fires after `HTMLImageElement.decode()` resolves, by which point
|
|
92
|
+
// Chrome has already nulled the underlying DOM event's `target`, so `native.source` below reads
|
|
93
|
+
// `undefined` on react-native-web (confirmed empirically — `event.target` reads non-null only
|
|
94
|
+
// when read synchronously inside `onload`, before any `await`) and this handler never sets
|
|
95
|
+
// `loadedAspect` there. Native's real `Image` reports `source.{width,height}` on its `onLoad`
|
|
96
|
+
// nativeEvent instead, which this still reads correctly. Kept rather than removed (Minor finding,
|
|
97
|
+
// Fable's implementation review) because a non-bundled source with no static dimensions (e.g. a
|
|
98
|
+
// bare `{ uri }`) is still possible on native, even though `cli/workspace.ts` only ever generates
|
|
99
|
+
// a bundled `require()` today.
|
|
100
|
+
const onLogoLoad = (e: NativeSyntheticEvent<ImageLoadEventData>) => {
|
|
101
|
+
const native = e.nativeEvent as unknown as { source?: { width: number; height: number } };
|
|
102
|
+
const width = native.source?.width;
|
|
103
|
+
const height = native.source?.height;
|
|
104
|
+
if (width && height) setLoadedAspect(width / height);
|
|
105
|
+
};
|
|
106
|
+
const onLogoError = () => setLogoFailed(true);
|
|
107
|
+
// Wrap detection for the "mark" row: the name Text's own measured height, not `onTextLayout`
|
|
108
|
+
// (react-native-web's `Text` doesn't implement it) and not a character-count heuristic (wraps at
|
|
109
|
+
// a different length per name). More than one line of `CATALOG_TYPE.brand` means it wrapped.
|
|
110
|
+
const NAME_LINE_HEIGHT = Math.round(CATALOG_TYPE.brand * 1.3);
|
|
111
|
+
const onNameLayout = (e: LayoutChangeEvent) => {
|
|
112
|
+
setNameWrapped(e.nativeEvent.layout.height > NAME_LINE_HEIGHT * 1.5);
|
|
113
|
+
};
|
|
114
|
+
|
|
65
115
|
return (
|
|
66
116
|
<View role="navigation" aria-label="Catalog pages" style={styles.sidebar}>
|
|
67
117
|
<ScrollView style={styles.scroll} contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
|
68
|
-
<
|
|
69
|
-
|
|
118
|
+
<View style={styles.header}>
|
|
119
|
+
{/* Invisible — exists only so a configured logo's `onLoad`/`onError` can fire at all
|
|
120
|
+
while `header` is still `'text'` (the aspect isn't known yet). Once `logoAspect`
|
|
121
|
+
resolves (or the image fails), `header` switches and this prober stops rendering. */}
|
|
122
|
+
{attemptingLogo && logoAspect === undefined && (
|
|
123
|
+
<Image source={logoImageSource} style={styles.logoProbe} onLoad={onLogoLoad} onError={onLogoError} />
|
|
124
|
+
)}
|
|
125
|
+
{header === 'wordmark' && (
|
|
126
|
+
<>
|
|
127
|
+
<Image
|
|
128
|
+
source={logoImageSource}
|
|
129
|
+
accessibilityRole="image"
|
|
130
|
+
accessibilityLabel={logo}
|
|
131
|
+
resizeMode="contain"
|
|
132
|
+
style={[styles.wordmark, { width: Math.min(CATALOG_LAYOUT.logoWordmarkHeight * (logoAspect ?? 0), 228) }]}
|
|
133
|
+
onLoad={onLogoLoad}
|
|
134
|
+
onError={onLogoError}
|
|
135
|
+
/>
|
|
136
|
+
<Text style={[styles.subtitle, styles.subtitleAfterWordmark]}>{caption}</Text>
|
|
137
|
+
</>
|
|
138
|
+
)}
|
|
139
|
+
{header === 'mark' && (
|
|
140
|
+
<View style={[styles.inlineHeader, nameWrapped && styles.inlineHeaderTop]}>
|
|
141
|
+
<Image source={logoImageSource} resizeMode="contain" style={styles.mark} onLoad={onLogoLoad} onError={onLogoError} />
|
|
142
|
+
<View style={styles.inlineText}>
|
|
143
|
+
<Text style={styles.logo} onLayout={onNameLayout}>{logo}</Text>
|
|
144
|
+
<Text style={styles.subtitle}>{caption}</Text>
|
|
145
|
+
</View>
|
|
146
|
+
</View>
|
|
147
|
+
)}
|
|
148
|
+
{header === 'text' && (
|
|
149
|
+
<>
|
|
150
|
+
<Text style={styles.logo}>{logo}</Text>
|
|
151
|
+
<Text style={styles.subtitle}>{caption}</Text>
|
|
152
|
+
</>
|
|
153
|
+
)}
|
|
154
|
+
</View>
|
|
70
155
|
|
|
71
156
|
<CatalogSearchInput value={query} onChangeText={setQuery} placeholder="Filter components…" />
|
|
72
157
|
|
|
@@ -97,8 +182,16 @@ const styles = StyleSheet.create({
|
|
|
97
182
|
paddingHorizontal: CATALOG_LAYOUT.sidebarPaddingX,
|
|
98
183
|
paddingBottom: CATALOG_LAYOUT.sidebarPaddingTop,
|
|
99
184
|
},
|
|
185
|
+
header: { marginBottom: 20 },
|
|
100
186
|
logo: { fontSize: CATALOG_TYPE.brand, fontWeight: '800', color: CATALOG_COLOR.text },
|
|
101
|
-
subtitle: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: 2
|
|
187
|
+
subtitle: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: 2 },
|
|
188
|
+
subtitleAfterWordmark: { marginTop: CATALOG_LAYOUT.logoCaptionGap },
|
|
189
|
+
mark: { width: CATALOG_LAYOUT.logoMark, height: CATALOG_LAYOUT.logoMark },
|
|
190
|
+
wordmark: { height: CATALOG_LAYOUT.logoWordmarkHeight, alignSelf: 'flex-start' },
|
|
191
|
+
logoProbe: { position: 'absolute', width: 1, height: 1, opacity: 0 },
|
|
192
|
+
inlineHeader: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md },
|
|
193
|
+
inlineHeaderTop: { alignItems: 'flex-start' },
|
|
194
|
+
inlineText: { flex: 1, minWidth: 0 },
|
|
102
195
|
empty: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, paddingHorizontal: CATALOG_LAYOUT.navItemPaddingX, paddingVertical: 8 },
|
|
103
196
|
groupLabel: {
|
|
104
197
|
fontSize: CATALOG_TYPE.xs,
|
|
@@ -4,8 +4,9 @@ import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE } from './tokens';
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Wraps one token's rendered example with a grounded "when to use this" note beneath it — the
|
|
7
|
-
* shared shape behind every token-gallery row (Spacing, Type Scale, …) in both
|
|
8
|
-
* and
|
|
7
|
+
* shared shape behind every token-gallery row (Spacing, Type Scale, …) in both the starter kit's
|
|
8
|
+
* own token pages and the catalog framework's own `kit-host/viewer-pages/` pages. The row's own
|
|
9
|
+
* content (a spacing bar, a type sample, …) is
|
|
9
10
|
* freeform `children`; only the value-plus-use-note stacking is standardized here.
|
|
10
11
|
*
|
|
11
12
|
* Draws a 1px `CATALOG_COLOR.border` bottom divider by default (the same divider PropsTable's rows use) so a stack of TokenRows
|
package/native/catalog/index.ts
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
* token group, each with a `render()` that uses that app's real components) and a `NavGroup[]`
|
|
7
7
|
* (how to bucket those sections in the sidebar), then render a single `<CatalogShell />`.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* A page is written with `defineCatalogPage()` from this barrel and placed next to its component
|
|
10
|
+
* (or standalone, for tokens/recipes) — see the starter kit under `starter-kit/` for worked
|
|
11
|
+
* examples, and `kit-host/viewer-pages/` for pages documenting this framework's own pieces. This
|
|
12
|
+
* barrel itself stays free of any specific component (including its own) so it can be copied into
|
|
13
|
+
* a different app's repo as-is.
|
|
14
14
|
*/
|
|
15
15
|
export { CatalogShell } from './CatalogShell';
|
|
16
16
|
export { CatalogSidebar } from './CatalogSidebar';
|
|
@@ -33,6 +33,7 @@ export type { ComponentManifestEntry, ManifestExample } from './manifest';
|
|
|
33
33
|
export { defineCatalogPage, buildCatalogSections } from './pageApi';
|
|
34
34
|
export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRecord } from './pageApi';
|
|
35
35
|
export type { PropDef, SectionDef, NavGroup, SpecimenSize, ComparisonDef, ComparisonCell, ComparisonAxisItem, PreviewWidths } from './types';
|
|
36
|
+
export type { ListGroup, ListItem } from './comparison';
|
|
36
37
|
export {
|
|
37
38
|
CATALOG_TYPE,
|
|
38
39
|
CATALOG_TYPE_USE,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Sidebar logo header layout (design `docs/design/2026-10-08-ds-viewer-sidebar-logo-approved.html`,
|
|
2
|
+
* "C · Adaptive", approved 2026-10-08): the configured logo image's own aspect ratio decides the
|
|
3
|
+
* layout — no config field chooses it. An unknown or invalid aspect falls back to the safer
|
|
4
|
+
* text-only header rather than guessing (`CatalogSidebar.tsx` renders this case until the image's
|
|
5
|
+
* `onLoad` reports a real size, and whenever there is no logo, it failed to load, or its file is
|
|
6
|
+
* missing). */
|
|
7
|
+
export const LOGO_WORDMARK_MIN_ASPECT = 2;
|
|
8
|
+
|
|
9
|
+
export function logoLayout(aspect: number | undefined): 'text' | 'mark' | 'wordmark' {
|
|
10
|
+
if (aspect === undefined || !Number.isFinite(aspect) || aspect <= 0) return 'text';
|
|
11
|
+
return aspect > LOGO_WORDMARK_MIN_ASPECT ? 'wordmark' : 'mark';
|
|
12
|
+
}
|
|
@@ -59,13 +59,22 @@ export interface GeneratedComponent {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function mergePropNotes(component: GeneratedComponent, propNotes: Record<string, string> | undefined): PropDef[] {
|
|
62
|
-
|
|
62
|
+
const own = component.props.map((prop) => ({
|
|
63
63
|
name: prop.name,
|
|
64
64
|
type: prop.type,
|
|
65
65
|
required: prop.required,
|
|
66
66
|
default: prop.default,
|
|
67
67
|
desc: propNotes?.[prop.name] ? `${prop.desc} ${propNotes[prop.name]}`.trim() : prop.desc,
|
|
68
68
|
}));
|
|
69
|
+
// Design §3: props inherited from node_modules (e.g. `extends TextInputProps`) are summarised as
|
|
70
|
+
// one row per source instead of being listed individually.
|
|
71
|
+
const inherited = component.inheritedFrom.map((source) => ({
|
|
72
|
+
name: `${source} props`,
|
|
73
|
+
type: 'inherited',
|
|
74
|
+
required: true,
|
|
75
|
+
desc: `Plus all ${source} props, not listed individually.`,
|
|
76
|
+
}));
|
|
77
|
+
return [...own, ...inherited];
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
function orderGroups(groupIds: Map<string, string[]>, groupOrder: string[] | undefined): { label: string; ids: string[] }[] {
|
package/native/catalog/tokens.ts
CHANGED
|
@@ -58,6 +58,11 @@ export const CATALOG_LAYOUT = {
|
|
|
58
58
|
factPaddingY: 9,
|
|
59
59
|
blockLabelGap: 14,
|
|
60
60
|
blockGap: 28,
|
|
61
|
+
/** Sidebar logo header (design `docs/design/2026-10-08-ds-viewer-sidebar-logo-approved.html`,
|
|
62
|
+
* "C · Adaptive", approved 2026-10-08) — see `native/catalog/logoLayout.ts`. */
|
|
63
|
+
logoMark: 32,
|
|
64
|
+
logoWordmarkHeight: 28,
|
|
65
|
+
logoCaptionGap: 6,
|
|
61
66
|
} as const;
|
|
62
67
|
|
|
63
68
|
/** Max width of the main content column. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krapwoo/ds-viewer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A browsable, always-current catalog for an Expo / React Native design system.",
|
|
@@ -25,9 +25,8 @@
|
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"native/catalog",
|
|
28
|
-
"!native/catalog/CatalogExample.tsx",
|
|
29
|
-
"!native/catalog/CatalogFrameworkExample.tsx",
|
|
30
28
|
"!native/catalog/**/__tests__",
|
|
29
|
+
"starter-kit",
|
|
31
30
|
"dist/cli",
|
|
32
31
|
"dist/config",
|
|
33
32
|
"config/index.ts",
|
|
@@ -39,22 +38,27 @@
|
|
|
39
38
|
"react": "*",
|
|
40
39
|
"react-dom": "*",
|
|
41
40
|
"react-native": "*",
|
|
42
|
-
"react-native-
|
|
43
|
-
"react-native-
|
|
41
|
+
"react-native-safe-area-context": "*",
|
|
42
|
+
"react-native-web": "*"
|
|
44
43
|
},
|
|
45
44
|
"peerDependenciesMeta": {
|
|
46
|
-
"react-native-safe-area-context": {
|
|
45
|
+
"react-native-safe-area-context": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"typescript": "^6.0.0"
|
|
50
51
|
},
|
|
51
52
|
"devDependencies": {
|
|
52
|
-
"@types/node": "^22.0.0"
|
|
53
|
+
"@types/node": "^22.0.0",
|
|
54
|
+
"puppeteer": "^25.12.0"
|
|
53
55
|
},
|
|
54
56
|
"scripts": {
|
|
55
57
|
"build": "tsc -p tsconfig.json",
|
|
56
58
|
"test": "node --experimental-strip-types --test 'cli/**/*.test.ts' 'config/**/*.test.ts' 'native/catalog/__tests__/*.test.ts'",
|
|
57
59
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
60
|
+
"kit:dev": "npm run build && cd kit-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js dev",
|
|
61
|
+
"check:catalog": "npm run build && node scripts/checkCatalogConsole.mjs kit-host",
|
|
58
62
|
"release": "node scripts/release.mjs"
|
|
59
63
|
}
|
|
60
64
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# When to use X vs Y
|
|
2
|
+
|
|
3
|
+
Disambiguation for components whose names or looks overlap, so a new screen reaches for the right
|
|
4
|
+
one on the first try instead of the closest-looking one. Each entry names the deciding question —
|
|
5
|
+
answer it and the choice falls out. For the full prop/variant/state list per component, read
|
|
6
|
+
`native/catalog/manifest.ts`'s `buildComponentManifest()` output (rendered live at the catalog's
|
|
7
|
+
"Manifest" page) rather than guessing from a component's name alone.
|
|
8
|
+
|
|
9
|
+
## InputField vs SearchField vs Dropdown
|
|
10
|
+
|
|
11
|
+
All three render on the same field chrome (`FieldContainer`) and can look identical at rest. The
|
|
12
|
+
deciding question: **what happens when the user taps in?**
|
|
13
|
+
|
|
14
|
+
- **InputField** — a labeled field with a *floating label* (the label shrinks to a caption once
|
|
15
|
+
active/filled). Use for a named piece of data with a fixed identity across the interaction — "To",
|
|
16
|
+
"From", "Arrive by", a person's name. Supports a live `editable` TextInput mode *or* a tappable
|
|
17
|
+
picker mode (`active` forced from outside, e.g. by a Dropdown/BottomSheet you're driving).
|
|
18
|
+
- **SearchField** — a single-purpose live text input with a permanent leading search icon and no
|
|
19
|
+
floating label (there's nothing to float to — "search" *is* the label, implied by the icon). Use
|
|
20
|
+
for free-text filtering/searching, never for a named field with a fixed value.
|
|
21
|
+
- **Dropdown** — not a text input at all. A label-above-value trigger that opens a `BottomSheet`
|
|
22
|
+
picker with a fixed option list. Use when the answer is one of a small, known set of choices
|
|
23
|
+
(a direction, a filter value) — never for free text.
|
|
24
|
+
|
|
25
|
+
Rule of thumb: known short list → **Dropdown**. Free-text filter/search → **SearchField**. Anything
|
|
26
|
+
else the user types or picks, with its own persistent label → **InputField**.
|
|
27
|
+
|
|
28
|
+
## Button vs Pill
|
|
29
|
+
|
|
30
|
+
Both are tappable, labeled, and can carry an icon.
|
|
31
|
+
|
|
32
|
+
- **Button** — an *action*. Something happens when you tap it (save, confirm, navigate, submit).
|
|
33
|
+
Five weights (`primary`/`secondary`/`tertiary`/`white`/`ghost`) express visual priority among
|
|
34
|
+
multiple actions on the same screen.
|
|
35
|
+
- **Pill** — a *selection toggle*, not an action. Tapping it flips a `selected`/`not_selected`
|
|
36
|
+
state that persists (a filter chip, a saved-place shortcut). If tapping it doesn't leave something
|
|
37
|
+
visibly "on," it should be a Button, not a Pill.
|
|
38
|
+
|
|
39
|
+
## Toast vs Banner
|
|
40
|
+
|
|
41
|
+
Both show a status message with an icon and optional action, and both support the same five
|
|
42
|
+
semantic variants.
|
|
43
|
+
|
|
44
|
+
- **Toast** — *transient* and *self-contained*. Appears on top of the current screen, unrelated to
|
|
45
|
+
the content beneath it, and is expected to go away on its own or via its own action ("Undo",
|
|
46
|
+
"Retry"). Owns no layout space — it floats.
|
|
47
|
+
- **Banner** — *persistent* and *in-flow*. Lives inside the screen's own layout (top of a list,
|
|
48
|
+
inside a card stack) for as long as the condition it describes is true ("Fare increase", "Line
|
|
49
|
+
suspended"). Never auto-dismisses; the condition or an explicit user action clears it.
|
|
50
|
+
|
|
51
|
+
If the message is about something happening *right now, because of something the user just did* →
|
|
52
|
+
Toast. If it's a standing fact about the current screen's data → Banner.
|
|
53
|
+
|
|
54
|
+
## Badge vs Pill vs a Toast/Banner variant
|
|
55
|
+
|
|
56
|
+
All three can render the same five semantic colors, which makes them easy to reach for
|
|
57
|
+
interchangeably. They aren't interchangeable:
|
|
58
|
+
|
|
59
|
+
- **Badge** — *read-only status label*. Not tappable. Use inline next to other content ("On time"
|
|
60
|
+
next to a train row, "Suspended" next to a line name).
|
|
61
|
+
- **Pill** — *tappable selection*. Use it when the same-looking chip needs to respond to a tap and
|
|
62
|
+
hold a selected state.
|
|
63
|
+
- **Toast/Banner variant** — the color describes a *message about the whole screen or a transient
|
|
64
|
+
event*, not a single inline data point. If you're tagging one row's status, that's a Badge; if
|
|
65
|
+
you're telling the user something happened, that's a Toast or Banner.
|
|
66
|
+
|
|
67
|
+
## Dialog vs BottomSheet
|
|
68
|
+
|
|
69
|
+
Both are overlay containers with a backdrop.
|
|
70
|
+
|
|
71
|
+
- **Dialog** — center-screen, fades and scales in. Use for a short, focused decision that
|
|
72
|
+
interrupts the flow (confirm/cancel, a single form). Named `Dialog` (not `Modal`) specifically to
|
|
73
|
+
avoid shadowing React Native's own built-in `Modal`.
|
|
74
|
+
- **BottomSheet** — bottom-anchored, slides up, height driven by content up to 90% of the screen.
|
|
75
|
+
Use for anything longer, browsable, or that benefits from a `TopNav`/`Dock` header-footer
|
|
76
|
+
structure (an option picker, a detail view, a multi-field form). If the content would need its
|
|
77
|
+
own internal scrolling, it's a BottomSheet, not a Dialog.
|
|
78
|
+
|
|
79
|
+
## Card vs ListItem (inside a List)
|
|
80
|
+
|
|
81
|
+
Both hold a leading element, a title, and secondary text.
|
|
82
|
+
|
|
83
|
+
- **ListItem** (inside **List**) — a *row in a set of peers* — settings, a menu, search results.
|
|
84
|
+
Rows are visually light (no individual shadow/border) because the *List* container supplies the
|
|
85
|
+
shared surface and the dividers between them.
|
|
86
|
+
- **Card** — a *standalone, self-contained unit* with its own shadow and rounded surface. Use when
|
|
87
|
+
the content doesn't belong to a homogeneous list — a single trip summary, a promo, one dashboard
|
|
88
|
+
tile sitting alongside other, differently-shaped content.
|
|
89
|
+
|
|
90
|
+
If you're about to render more than one of the same shape in a vertical stack with dividers between
|
|
91
|
+
them, that's `List`/`ListItem`, not a stack of `Card`s.
|
|
92
|
+
|
|
93
|
+
## SegmentedToggle vs UnderlineTabs
|
|
94
|
+
|
|
95
|
+
Same `options`/`value`/`onChange` API, same job (switch between a small set of views) — the
|
|
96
|
+
difference is purely visual weight.
|
|
97
|
+
|
|
98
|
+
- **SegmentedToggle** — a filled, recessed track with a sliding white thumb. Heavier visual weight;
|
|
99
|
+
use when the choice is a primary, prominent control on the screen (e.g. Map/List view).
|
|
100
|
+
- **UnderlineTabs** — left-aligned labels over a hairline rule with a sliding underline. Quieter;
|
|
101
|
+
use for secondary navigation within a screen that already has a clear primary focus.
|
|
102
|
+
|
|
103
|
+
## Checkbox vs Switch vs Radio
|
|
104
|
+
|
|
105
|
+
All three are binary-ish controls; the difference is what the value means and how many can be true
|
|
106
|
+
at once.
|
|
107
|
+
|
|
108
|
+
- **Checkbox** — an independent on/off fact about *this one item* ("Remember this trip"). Any number
|
|
109
|
+
of checkboxes on a screen can be checked at once, independently.
|
|
110
|
+
- **Switch** — an on/off *setting* that takes effect immediately (no separate "Save" step). Reach
|
|
111
|
+
for Switch specifically when toggling it *does something right away*; reach for Checkbox when it's
|
|
112
|
+
just recording a fact that a future action (a form submit) will act on.
|
|
113
|
+
- **Radio** — one selection from a *mutually exclusive* set (only one can be selected across a group
|
|
114
|
+
of Radios sharing one consumer-held value). If checking one should un-check another, it's Radio,
|
|
115
|
+
never Checkbox.
|
|
116
|
+
|
|
117
|
+
## EmptyState vs Banner
|
|
118
|
+
|
|
119
|
+
- **EmptyState** — fills the *entire* content area of a screen or section because there is
|
|
120
|
+
*nothing else to show* (no results, no saved items, first run). Centered, icon-led, optionally one
|
|
121
|
+
action.
|
|
122
|
+
- **Banner** — sits *alongside* other real content, describing a condition about that content or the
|
|
123
|
+
screen in general. If the screen would otherwise be blank, use EmptyState; if it has content and
|
|
124
|
+
you're adding a note above/around it, use Banner.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { defineCatalogPage } from '@krapwoo/ds-viewer';
|
|
3
|
+
import { AnimatedChevron } from './AnimatedChevron';
|
|
4
|
+
import { DS_SEMANTIC } from '../../tokens';
|
|
5
|
+
|
|
6
|
+
export default defineCatalogPage({
|
|
7
|
+
component: 'AnimatedChevron',
|
|
8
|
+
group: 'Sub-Parts',
|
|
9
|
+
description: 'A chevron that morphs between down (collapsed) and up (expanded) — flipping vertically in place instead of rotating through a sideways-pointing angle, and instead of swapping icons. The morph Banner\'s own collapsible header uses for its disclosure indicator. Not commonly used on its own; exported for building a custom disclosure/accordion toggle.',
|
|
10
|
+
a11y: 'Purely decorative — no accessibility role of its own. Wrap it in an accessible parent (the way Banner\'s own header Pressable does) if the toggle needs to be announced.',
|
|
11
|
+
variants: {
|
|
12
|
+
items: [
|
|
13
|
+
{ key: 'collapsed', name: 'Collapsed', props: { expanded: false }, node: <AnimatedChevron expanded={false} /> },
|
|
14
|
+
{ key: 'expanded', name: 'Expanded', props: { expanded: true }, node: <AnimatedChevron expanded={true} /> },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
states: {
|
|
18
|
+
items: [
|
|
19
|
+
{ key: 'size', name: 'Custom size', node: <AnimatedChevron expanded={false} size={28} /> },
|
|
20
|
+
{ key: 'color', name: 'Custom color', node: <AnimatedChevron expanded={true} color={DS_SEMANTIC.emphasis.info} /> },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, Easing } from 'react-native';
|
|
3
|
+
import { Icon } from '../../icons/Icon.native';
|
|
4
|
+
import { DS_MOTION_DURATION, DS_MOTION_EASING } from '../../tokens';
|
|
5
|
+
|
|
6
|
+
export interface AnimatedChevronProps {
|
|
7
|
+
/** When true the chevron points up; when false it points down. */
|
|
8
|
+
expanded: boolean;
|
|
9
|
+
/** Icon size in px. */
|
|
10
|
+
size?: number;
|
|
11
|
+
/** Icon color. */
|
|
12
|
+
color?: string;
|
|
13
|
+
/** Morph duration in ms. Defaults to DS_MOTION_DURATION.base (240). */
|
|
14
|
+
duration?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A chevron that morphs between down (collapsed) and up (expanded) by flipping vertically in place
|
|
19
|
+
* (`scaleY` 1 → 0 → -1) rather than rotating through a sideways-pointing intermediate angle — a
|
|
20
|
+
* plain chevron-down glyph mirrored on its own vertical centre (`scaleY: -1`) is exactly chevron-up,
|
|
21
|
+
* so no second icon or path swap is needed. Uses the core `Animated` API with `useNativeDriver:
|
|
22
|
+
* true` (transform-only), so it's smooth on the New Architecture in dev and release.
|
|
23
|
+
*/
|
|
24
|
+
export function AnimatedChevron({ expanded, size = 16, color, duration = DS_MOTION_DURATION.base }: AnimatedChevronProps) {
|
|
25
|
+
const progress = useRef(new Animated.Value(expanded ? 1 : 0)).current;
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const anim = Animated.timing(progress, {
|
|
29
|
+
toValue: expanded ? 1 : 0,
|
|
30
|
+
duration,
|
|
31
|
+
// `standard` — an in-place morph (a swap), per DS_MOTION_EASING_USE.
|
|
32
|
+
easing: Easing.bezier(...DS_MOTION_EASING.standard),
|
|
33
|
+
useNativeDriver: true,
|
|
34
|
+
});
|
|
35
|
+
anim.start();
|
|
36
|
+
return () => anim.stop();
|
|
37
|
+
}, [expanded, duration, progress]);
|
|
38
|
+
|
|
39
|
+
const scaleY = progress.interpolate({ inputRange: [0, 1], outputRange: [1, -1] });
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Animated.View style={{ transform: [{ scaleY }] }}>
|
|
43
|
+
<Icon name="chevron-down" size={size} color={color} />
|
|
44
|
+
</Animated.View>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { defineCatalogPage, type ComparisonDef } from '@krapwoo/ds-viewer';
|
|
3
|
+
import { Avatar } from './Avatar';
|
|
4
|
+
import { DS_SEMANTIC } from '../../tokens';
|
|
5
|
+
|
|
6
|
+
// Grid comparisons: each cell is a real instance with exactly the props its row and column name.
|
|
7
|
+
// Never build these by multiplying `variants` with `states` — those are pre-rendered nodes and
|
|
8
|
+
// cannot combine.
|
|
9
|
+
function grid(
|
|
10
|
+
rowLabel: string,
|
|
11
|
+
columnLabel: string,
|
|
12
|
+
rows: { key: string; label: string }[],
|
|
13
|
+
columns: { key: string; label: string }[],
|
|
14
|
+
cell: (row: string, column: string) => React.ReactNode,
|
|
15
|
+
size?: ComparisonDef['size'],
|
|
16
|
+
): ComparisonDef {
|
|
17
|
+
return {
|
|
18
|
+
rowLabel,
|
|
19
|
+
columnLabel,
|
|
20
|
+
rows,
|
|
21
|
+
columns,
|
|
22
|
+
cells: rows.flatMap((row) => columns.map((column) => ({ rowKey: row.key, columnKey: column.key, node: cell(row.key, column.key) }))),
|
|
23
|
+
size,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Avatar: content Kind × Size (Avatar's `size` is continuous; 24 / 40 default / 64 is the sweep).
|
|
28
|
+
const AVATAR_COMPARISON = grid(
|
|
29
|
+
'Kind',
|
|
30
|
+
'Size',
|
|
31
|
+
[
|
|
32
|
+
{ key: 'image', label: 'Image' },
|
|
33
|
+
{ key: 'icon', label: 'Icon' },
|
|
34
|
+
{ key: 'initials', label: 'Initials fallback' },
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
{ key: 'small', label: 'Small · 24' },
|
|
38
|
+
{ key: 'medium', label: 'Medium · 40 (default)' },
|
|
39
|
+
{ key: 'large', label: 'Large · 64' },
|
|
40
|
+
],
|
|
41
|
+
(row, column) => {
|
|
42
|
+
const size = column === 'small' ? 24 : column === 'large' ? 64 : 40;
|
|
43
|
+
if (row === 'image') return <Avatar imageUrl="https://i.pravatar.cc/100" accessibilityLabel="Jordan Lee" size={size} />;
|
|
44
|
+
if (row === 'icon') return <Avatar iconName="users" accessibilityLabel="Guest" size={size} />;
|
|
45
|
+
return <Avatar initials="JL" accessibilityLabel="Jordan Lee" size={size} />;
|
|
46
|
+
},
|
|
47
|
+
'compact',
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export default defineCatalogPage({
|
|
51
|
+
component: 'Avatar',
|
|
52
|
+
group: 'Surfaces',
|
|
53
|
+
specimenSize: 'compact',
|
|
54
|
+
comparison: AVATAR_COMPARISON,
|
|
55
|
+
description: 'A circular image, or an initials fallback on a solid fill when there\'s no image (or it fails to load).',
|
|
56
|
+
a11y: 'Renders with accessibilityRole="image"; pass accessibilityLabel for a meaningful name, otherwise it falls back to the initials text.',
|
|
57
|
+
variants: {
|
|
58
|
+
items: [
|
|
59
|
+
{ key: 'image', name: 'Image', node: <Avatar imageUrl="https://i.pravatar.cc/100" accessibilityLabel="Jordan Lee" /> },
|
|
60
|
+
{ key: 'icon', name: 'Icon', node: <Avatar iconName="users" accessibilityLabel="Guest" /> },
|
|
61
|
+
{ key: 'initials', name: 'Initials fallback', node: <Avatar initials="JL" accessibilityLabel="Jordan Lee" /> },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
states: {
|
|
65
|
+
items: [
|
|
66
|
+
{ key: 'small', name: 'Small', node: <Avatar initials="JL" size={24} /> },
|
|
67
|
+
{ key: 'medium', name: 'Medium (default)', node: <Avatar initials="JL" size={40} /> },
|
|
68
|
+
{ key: 'large', name: 'Large', node: <Avatar initials="JL" size={64} /> },
|
|
69
|
+
{ key: 'custom-color', name: 'Custom colour', node: <Avatar initials="AC" backgroundColor={DS_SEMANTIC.emphasis.info} /> },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, Text, Image, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { DS_SEMANTIC, DS_FONT_WEIGHT } from '../../tokens';
|
|
4
|
+
import { Icon } from '../../icons/Icon.native';
|
|
5
|
+
import type { IconName } from '../../icons';
|
|
6
|
+
|
|
7
|
+
export interface AvatarProps {
|
|
8
|
+
/** Remote image URL. Takes precedence over `iconName`/`initials`; falls back to them when omitted
|
|
9
|
+
* or the image fails to load. */
|
|
10
|
+
imageUrl?: string;
|
|
11
|
+
/** Icon shown instead of initials — e.g. for a generic/anonymous avatar. Takes precedence over
|
|
12
|
+
* `initials` when there's no image. */
|
|
13
|
+
iconName?: IconName;
|
|
14
|
+
/** Shown when there's no image or icon — the first 1-2 characters are used, uppercased. */
|
|
15
|
+
initials?: string;
|
|
16
|
+
/** Diameter in px. @default 40 */
|
|
17
|
+
size?: number;
|
|
18
|
+
/** Fill colour behind the icon/initials. Defaults to a neutral muted surface (with dark
|
|
19
|
+
* foreground); passing a custom colour switches the icon/initials to light (inverse) text,
|
|
20
|
+
* assuming a saturated/dark fill — match that pairing if you override this. */
|
|
21
|
+
backgroundColor?: string;
|
|
22
|
+
accessibilityLabel?: string;
|
|
23
|
+
style?: StyleProp<ViewStyle>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A circular image, icon, or initials fallback on a solid fill — in that order of precedence. */
|
|
27
|
+
export function Avatar({
|
|
28
|
+
imageUrl,
|
|
29
|
+
iconName,
|
|
30
|
+
initials,
|
|
31
|
+
size = 40,
|
|
32
|
+
backgroundColor,
|
|
33
|
+
accessibilityLabel,
|
|
34
|
+
style,
|
|
35
|
+
}: AvatarProps) {
|
|
36
|
+
const [imageFailed, setImageFailed] = React.useState(false);
|
|
37
|
+
const showImage = !!imageUrl && !imageFailed;
|
|
38
|
+
const showIcon = !showImage && !!iconName;
|
|
39
|
+
const dimension = { width: size, height: size, borderRadius: size / 2 };
|
|
40
|
+
// The default fill (surface.muted) is light, so its icon/initials need dark text; a caller-supplied
|
|
41
|
+
// backgroundColor is assumed saturated/dark (the existing convention — see the "Custom colour"
|
|
42
|
+
// catalog example), so it keeps the light inverse text that pairing needs.
|
|
43
|
+
const contentColor = backgroundColor ? DS_SEMANTIC.text.inverse : DS_SEMANTIC.text.regular;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<View
|
|
47
|
+
style={[styles.circle, dimension, !showImage && { backgroundColor: backgroundColor ?? DS_SEMANTIC.surface.muted }, style]}
|
|
48
|
+
accessible
|
|
49
|
+
accessibilityRole="image"
|
|
50
|
+
accessibilityLabel={accessibilityLabel ?? initials}
|
|
51
|
+
>
|
|
52
|
+
{showImage ? (
|
|
53
|
+
<Image source={{ uri: imageUrl }} style={dimension} onError={() => setImageFailed(true)} />
|
|
54
|
+
) : showIcon ? (
|
|
55
|
+
<Icon name={iconName} size={Math.round(size * 0.5)} color={contentColor} />
|
|
56
|
+
) : (
|
|
57
|
+
<Text style={[styles.initials, { fontSize: Math.round(size * 0.4), color: contentColor }]} numberOfLines={1}>
|
|
58
|
+
{(initials ?? '?').slice(0, 2).toUpperCase()}
|
|
59
|
+
</Text>
|
|
60
|
+
)}
|
|
61
|
+
</View>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const styles = StyleSheet.create({
|
|
66
|
+
circle: {
|
|
67
|
+
alignItems: 'center',
|
|
68
|
+
justifyContent: 'center',
|
|
69
|
+
overflow: 'hidden',
|
|
70
|
+
},
|
|
71
|
+
initials: {
|
|
72
|
+
fontWeight: DS_FONT_WEIGHT.semibold,
|
|
73
|
+
},
|
|
74
|
+
});
|