@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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { View, Text, Pressable, Animated, I18nManager, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { DS_SEMANTIC, DS_SPACING, DS_RADIUS, DS_TYPOGRAPHY, DS_ICON_SIZE, DS_SHADOW, DS_A11Y_MIN_TOUCH_TARGET } from '../../tokens';
|
|
4
|
+
import { Icon } from '../../icons/Icon.native';
|
|
5
|
+
import type { IconName } from '../../icons';
|
|
6
|
+
import { useSlideAnim } from './useSlideAnim';
|
|
7
|
+
|
|
8
|
+
export interface SegmentedToggleOption {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
/** Optional leading icon (16px) shown before the label. */
|
|
12
|
+
iconName?: IconName;
|
|
13
|
+
/** Optional count shown in a circular badge after the label (hidden when 0/undefined). */
|
|
14
|
+
badge?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SegmentedToggleProps {
|
|
18
|
+
/** The segments. */
|
|
19
|
+
options: SegmentedToggleOption[];
|
|
20
|
+
/** The currently-selected option `value`. */
|
|
21
|
+
value: string;
|
|
22
|
+
/** Selection handler. */
|
|
23
|
+
onChange: (value: string) => void;
|
|
24
|
+
style?: StyleProp<ViewStyle>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const TRACK_PADDING = DS_SPACING[100]; // recessed track inset
|
|
28
|
+
const GAP = DS_SPACING[100]; // space between segment slots
|
|
29
|
+
const THUMB_INSET = DS_SPACING[100]; // shrinks the thumb inside its slot for breathing room
|
|
30
|
+
const TRACK_COLOR = DS_SEMANTIC.surface.recessed; // track background — matches the secondary Button fill
|
|
31
|
+
const THUMB_COLOR = DS_SEMANTIC.surface.white; // selected thumb (white pill)
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Segmented control — a row of mutually-exclusive options on a recessed track. A white "thumb" lifts
|
|
35
|
+
* the selected option and slides horizontally between segments on change. The thumb sits inset from its
|
|
36
|
+
* slot so there's a little gap around it. Two or more options.
|
|
37
|
+
*/
|
|
38
|
+
export function SegmentedToggle({ options, value, onChange, style }: SegmentedToggleProps) {
|
|
39
|
+
const n = options.length;
|
|
40
|
+
const selectedIndex = Math.max(0, options.findIndex(o => o.value === value));
|
|
41
|
+
const [trackW, setTrackW] = useState(0);
|
|
42
|
+
// Which segment currently holds keyboard focus (null = none) — one state for the whole row, since
|
|
43
|
+
// only one segment can be focused at a time.
|
|
44
|
+
const [focusedValue, setFocusedValue] = useState<string | null>(null);
|
|
45
|
+
// Fixed equal-width segments computed analytically, so translateX-only can stay native-driven.
|
|
46
|
+
const anim = useSlideAnim(selectedIndex, true);
|
|
47
|
+
|
|
48
|
+
const innerW = Math.max(0, trackW - TRACK_PADDING * 2);
|
|
49
|
+
const segW = n > 0 ? (innerW - GAP * (n - 1)) / n : 0;
|
|
50
|
+
const thumbW = Math.max(0, segW - THUMB_INSET * 2);
|
|
51
|
+
// Left edge of the thumb at each index (segment left + inset). `track`'s `flexDirection: 'row'`
|
|
52
|
+
// auto-mirrors the segments themselves in RTL, but this absolute-positioned thumb's `left`/
|
|
53
|
+
// `translateX` doesn't — so in RTL, index `i` visually renders at the *mirrored* slot (n-1-i).
|
|
54
|
+
const thumbX =
|
|
55
|
+
n > 1
|
|
56
|
+
? anim.interpolate({
|
|
57
|
+
inputRange: options.map((_, i) => i),
|
|
58
|
+
outputRange: options.map((_, i) => {
|
|
59
|
+
const slot = I18nManager.isRTL ? n - 1 - i : i;
|
|
60
|
+
return TRACK_PADDING + slot * (segW + GAP) + THUMB_INSET;
|
|
61
|
+
}),
|
|
62
|
+
})
|
|
63
|
+
: TRACK_PADDING + THUMB_INSET;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<View
|
|
67
|
+
style={[styles.track, style]}
|
|
68
|
+
onLayout={e => setTrackW(e.nativeEvent.layout.width)}
|
|
69
|
+
accessibilityRole="tablist"
|
|
70
|
+
>
|
|
71
|
+
{trackW > 0 && (
|
|
72
|
+
<Animated.View
|
|
73
|
+
pointerEvents="none"
|
|
74
|
+
style={[styles.thumb, { width: thumbW, transform: [{ translateX: thumbX }] }]}
|
|
75
|
+
/>
|
|
76
|
+
)}
|
|
77
|
+
{options.map(opt => {
|
|
78
|
+
const selected = opt.value === value;
|
|
79
|
+
// Black label on the white thumb when selected; muted on the recessed track otherwise.
|
|
80
|
+
const color = selected ? DS_SEMANTIC.text.regular : DS_SEMANTIC.text.muted;
|
|
81
|
+
// The badge is a filled circle of the label colour; its number is white so it stays legible on
|
|
82
|
+
// both the black (selected) and muted-grey (unselected) circle.
|
|
83
|
+
const onColor = DS_SEMANTIC.surface.white;
|
|
84
|
+
return (
|
|
85
|
+
<Pressable
|
|
86
|
+
key={opt.value}
|
|
87
|
+
onPress={() => onChange(opt.value)}
|
|
88
|
+
onFocus={() => setFocusedValue(opt.value)}
|
|
89
|
+
onBlur={() => setFocusedValue(prev => (prev === opt.value ? null : prev))}
|
|
90
|
+
accessibilityRole="tab"
|
|
91
|
+
accessibilityLabel={opt.badge != null && opt.badge > 0 ? `${opt.label}, ${opt.badge}` : opt.label}
|
|
92
|
+
accessibilityState={{ selected }}
|
|
93
|
+
// Keyboard focus shows the same highlight as a press — a flat in-track target has no
|
|
94
|
+
// border to recolor, so focus mirrors the pressed treatment (same policy as ListItem).
|
|
95
|
+
style={({ pressed }) => [styles.segment, (pressed || focusedValue === opt.value) && styles.segmentPressed]}
|
|
96
|
+
>
|
|
97
|
+
{opt.iconName && <Icon name={opt.iconName} size={DS_ICON_SIZE.sm} color={color} />}
|
|
98
|
+
<Text style={[styles.label, { color }]}>{opt.label}</Text>
|
|
99
|
+
{opt.badge != null && opt.badge > 0 && (
|
|
100
|
+
<View style={[styles.badge, { backgroundColor: color }]}>
|
|
101
|
+
<Text style={[styles.badgeText, { color: onColor }]}>{opt.badge}</Text>
|
|
102
|
+
</View>
|
|
103
|
+
)}
|
|
104
|
+
</Pressable>
|
|
105
|
+
);
|
|
106
|
+
})}
|
|
107
|
+
</View>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const styles = StyleSheet.create({
|
|
112
|
+
track: {
|
|
113
|
+
flexDirection: 'row',
|
|
114
|
+
backgroundColor: TRACK_COLOR,
|
|
115
|
+
borderRadius: DS_RADIUS.round,
|
|
116
|
+
padding: TRACK_PADDING,
|
|
117
|
+
gap: GAP,
|
|
118
|
+
},
|
|
119
|
+
// The lifted pill behind the selected segment — slides horizontally on change.
|
|
120
|
+
thumb: {
|
|
121
|
+
position: 'absolute',
|
|
122
|
+
left: 0,
|
|
123
|
+
top: TRACK_PADDING + THUMB_INSET,
|
|
124
|
+
bottom: TRACK_PADDING + THUMB_INSET,
|
|
125
|
+
backgroundColor: THUMB_COLOR,
|
|
126
|
+
borderRadius: DS_RADIUS.round,
|
|
127
|
+
...DS_SHADOW.resting,
|
|
128
|
+
},
|
|
129
|
+
segment: {
|
|
130
|
+
flex: 1,
|
|
131
|
+
flexDirection: 'row',
|
|
132
|
+
alignItems: 'center',
|
|
133
|
+
justifyContent: 'center',
|
|
134
|
+
gap: DS_SPACING[200],
|
|
135
|
+
paddingVertical: DS_SPACING[600],
|
|
136
|
+
// The track's own padding + this segment's content otherwise falls short of the touch-target
|
|
137
|
+
// minimum; a fixed floor (not a spacing token — this is an accessibility guideline number, not a
|
|
138
|
+
// design choice) keeps the segment's compact visual padding while still guaranteeing a tappable size.
|
|
139
|
+
minHeight: DS_A11Y_MIN_TOUCH_TARGET,
|
|
140
|
+
},
|
|
141
|
+
segmentPressed: {
|
|
142
|
+
backgroundColor: DS_SEMANTIC.interaction.pressed,
|
|
143
|
+
borderRadius: DS_RADIUS.round,
|
|
144
|
+
},
|
|
145
|
+
label: {
|
|
146
|
+
// Matches the section-header label size (labelXs) it stands in for.
|
|
147
|
+
...DS_TYPOGRAPHY.labelXs,
|
|
148
|
+
},
|
|
149
|
+
// Fixed-diameter circle so the count badge stays round. 18 doesn't match a DS_SPACING/DS_ICON_SIZE
|
|
150
|
+
// step (16/20 are the neighbors) — sized to comfortably fit a 1-2 digit count at labelXs.
|
|
151
|
+
badge: {
|
|
152
|
+
width: 18,
|
|
153
|
+
height: 18,
|
|
154
|
+
borderRadius: DS_RADIUS.round,
|
|
155
|
+
alignItems: 'center',
|
|
156
|
+
justifyContent: 'center',
|
|
157
|
+
},
|
|
158
|
+
badgeText: {
|
|
159
|
+
...DS_TYPOGRAPHY.labelXs,
|
|
160
|
+
// Pinned to the badge's own 18px diameter (above) so the count sits centered in the circle.
|
|
161
|
+
lineHeight: 18,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { Animated, Easing } from 'react-native';
|
|
3
|
+
import { DS_MOTION_DURATION, DS_MOTION_EASING } from '../../tokens';
|
|
4
|
+
|
|
5
|
+
/** How long the selection indicator takes to slide to its new position. */
|
|
6
|
+
export const SLIDE_ANIM_MS = DS_MOTION_DURATION.base;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shared by SegmentedToggle and UnderlineTabs: an Animated.Value that eases to `selectedIndex`
|
|
10
|
+
* whenever it changes. Each component still does its own position math (SegmentedToggle computes
|
|
11
|
+
* fixed equal-width segments analytically; UnderlineTabs measures each tab's real width via onLayout)
|
|
12
|
+
* — those differ enough between the two that unifying them isn't safe to do blindly, but the "animate
|
|
13
|
+
* to the new index" mechanics underneath were byte-identical, so that part is shared here.
|
|
14
|
+
*/
|
|
15
|
+
export function useSlideAnim(selectedIndex: number, useNativeDriver: boolean) {
|
|
16
|
+
const anim = useRef(new Animated.Value(selectedIndex)).current;
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
Animated.timing(anim, {
|
|
20
|
+
toValue: selectedIndex,
|
|
21
|
+
duration: SLIDE_ANIM_MS,
|
|
22
|
+
// `standard` — a move that starts and ends on screen (a toggle/swap), per DS_MOTION_EASING_USE.
|
|
23
|
+
easing: Easing.bezier(...DS_MOTION_EASING.standard),
|
|
24
|
+
useNativeDriver,
|
|
25
|
+
}).start();
|
|
26
|
+
}, [selectedIndex, anim, useNativeDriver]);
|
|
27
|
+
|
|
28
|
+
return anim;
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { StyleSheet } from 'react-native';
|
|
3
|
+
import { defineCatalogPage } from '@krapwoo/ds-viewer';
|
|
4
|
+
import { Breathing } from './Shimmer';
|
|
5
|
+
import { DS_RADIUS, DS_SEMANTIC } from '../../tokens';
|
|
6
|
+
|
|
7
|
+
const styles = StyleSheet.create({
|
|
8
|
+
block: { width: 160, height: 16, borderRadius: DS_RADIUS.small },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export default defineCatalogPage({
|
|
12
|
+
component: 'Breathing',
|
|
13
|
+
group: 'Feedback',
|
|
14
|
+
description:
|
|
15
|
+
"The shared animated primitive behind Shimmer — a solid block that breathes between a low and high colour on one shared, module-level clock, so every Breathing/Shimmer instance on screen stays phase-locked instead of drifting out of sync. Exported so a custom composite skeleton can reuse the same pulse directly instead of going through Shimmer's fixed text/circle/container shapes.",
|
|
16
|
+
a11y: 'Renders a plain Animated.View with no role by default — pass accessibilityRole="progressbar" and accessibilityLabel yourself, the same way Shimmer does internally for its own standalone (non-grouped) blocks.',
|
|
17
|
+
variants: {
|
|
18
|
+
itemsFill: true,
|
|
19
|
+
items: [
|
|
20
|
+
{ key: 'default', name: 'Default', node: <Breathing style={styles.block} /> },
|
|
21
|
+
{
|
|
22
|
+
key: 'custom-colors',
|
|
23
|
+
name: 'Custom colours',
|
|
24
|
+
props: { low: DS_SEMANTIC.shade.info, high: DS_SEMANTIC.emphasis.info },
|
|
25
|
+
node: <Breathing style={styles.block} low={DS_SEMANTIC.shade.info} high={DS_SEMANTIC.emphasis.info} />,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, StyleSheet } from 'react-native';
|
|
3
|
+
import { defineCatalogPage } from '@krapwoo/ds-viewer';
|
|
4
|
+
import { Shimmer, SkeletonGroup } from './Shimmer';
|
|
5
|
+
import { DS_SPACING } from '../../tokens';
|
|
6
|
+
|
|
7
|
+
const styles = StyleSheet.create({
|
|
8
|
+
shimmerLines: { flex: 1, gap: DS_SPACING[400] },
|
|
9
|
+
previewRow: { flexDirection: 'row', alignItems: 'center', gap: DS_SPACING[600] },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export default defineCatalogPage({
|
|
13
|
+
component: 'Shimmer',
|
|
14
|
+
group: 'Feedback',
|
|
15
|
+
description: 'Loading placeholders that breathe while content loads. `text` stands in for one line of text (an element with 3 lines gets 3 stacked `text` Shimmers, not one tall one), `circle` is always for a circular element, and `container` covers everything else (Card, Banner, image, …) — sized to match the real element it replaces, not an arbitrary block. When several stand in for one element, wrap them in `SkeletonGroup`.',
|
|
16
|
+
whenToUse: 'A composite skeleton (several Shimmers standing in for one element) → wrap them in SkeletonGroup so it announces "Loading" once, not once per block. A single lone Shimmer needs no wrapper — it announces on its own.',
|
|
17
|
+
a11y: 'A standalone Shimmer is exposed as accessibilityRole="progressbar" + accessibilityLabel="Loading". Inside a SkeletonGroup, each block goes silent and the group carries one busy "Loading" announcement for the whole skeleton — so a screen reader says "Loading" once, not once per block.',
|
|
18
|
+
// One standalone example per `variant` enum value (`text` is the default, so it comes first),
|
|
19
|
+
// plus the composed "Circle + text" usage — shown here too, not just under States, so Variants
|
|
20
|
+
// reads as complete on its own.
|
|
21
|
+
variants: {
|
|
22
|
+
itemsFill: true,
|
|
23
|
+
items: [
|
|
24
|
+
{ key: 'text', name: 'Text (one line)', props: { variant: 'text' }, node: <Shimmer variant="text" width={160} /> },
|
|
25
|
+
{ key: 'circle', name: 'Circle', props: { variant: 'circle' }, node: <Shimmer variant="circle" size={40} /> },
|
|
26
|
+
{
|
|
27
|
+
key: 'container',
|
|
28
|
+
name: 'Container (matches Card)',
|
|
29
|
+
props: { variant: 'container' },
|
|
30
|
+
// Same radius Card itself renders at (DS_RADIUS.medium, via dimensionsForVariant) and a
|
|
31
|
+
// plausible real Card content height — a container skeleton should reserve the same
|
|
32
|
+
// layout space the real element will occupy, not an arbitrary block.
|
|
33
|
+
node: <Shimmer variant="container" height={96} />,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
key: 'circle-and-text',
|
|
37
|
+
name: 'Circle + text (2 lines)',
|
|
38
|
+
// SkeletonGroup so the whole avatar+2-lines skeleton announces "Loading" once, not three
|
|
39
|
+
// times (one per block) — it carries the caller's row layout itself.
|
|
40
|
+
node: (
|
|
41
|
+
<SkeletonGroup style={styles.previewRow}>
|
|
42
|
+
<Shimmer variant="circle" size={40} />
|
|
43
|
+
{/* Last line ~70% of the others' width — see ShimmerProps.variant's `'text'` doc. */}
|
|
44
|
+
<View style={styles.shimmerLines}>
|
|
45
|
+
<Shimmer variant="text" width={160} />
|
|
46
|
+
<Shimmer variant="text" width={112} />
|
|
47
|
+
</View>
|
|
48
|
+
</SkeletonGroup>
|
|
49
|
+
),
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
// Repeated here (not just in Variants) so States / Configurations is independently complete too.
|
|
54
|
+
states: {
|
|
55
|
+
itemsFill: true,
|
|
56
|
+
items: [
|
|
57
|
+
{
|
|
58
|
+
key: 'circle-and-text',
|
|
59
|
+
name: 'Circle + text (2 lines)',
|
|
60
|
+
// SkeletonGroup so the whole avatar+2-lines skeleton announces "Loading" once, not three
|
|
61
|
+
// times (one per block) — it carries the caller's row layout itself.
|
|
62
|
+
node: (
|
|
63
|
+
<SkeletonGroup style={styles.previewRow}>
|
|
64
|
+
<Shimmer variant="circle" size={40} />
|
|
65
|
+
{/* Last line ~70% of the others' width — see ShimmerProps.variant's `'text'` doc. */}
|
|
66
|
+
<View style={styles.shimmerLines}>
|
|
67
|
+
<Shimmer variant="text" width={160} />
|
|
68
|
+
<Shimmer variant="text" width={112} />
|
|
69
|
+
</View>
|
|
70
|
+
</SkeletonGroup>
|
|
71
|
+
),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
key: 'three-lines',
|
|
75
|
+
name: 'Three lines of text',
|
|
76
|
+
node: (
|
|
77
|
+
// Explicit px widths (not "100%") specifically so the last line's 70% ratio is a real,
|
|
78
|
+
// checkable relationship to the other two, not two independent percentages. Wrapped in a
|
|
79
|
+
// SkeletonGroup so all three lines announce as one "Loading" region.
|
|
80
|
+
<SkeletonGroup style={styles.shimmerLines}>
|
|
81
|
+
<Shimmer variant="text" width={200} />
|
|
82
|
+
<Shimmer variant="text" width={200} />
|
|
83
|
+
<Shimmer variant="text" width={140} />
|
|
84
|
+
</SkeletonGroup>
|
|
85
|
+
),
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from 'react';
|
|
2
|
+
import { View, Animated, Easing, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { DS_RADIUS, DS_SPACING, DS_MOTION_LOOP_DURATION } from '../../tokens';
|
|
4
|
+
import type { ShimmerProps, ShimmerVariant } from './Shimmer.types';
|
|
5
|
+
|
|
6
|
+
// True for any Shimmer rendered inside a <SkeletonGroup>. When set, individual blocks drop their own
|
|
7
|
+
// "Loading" announcement — the group announces once for the whole skeleton instead (see below).
|
|
8
|
+
const SkeletonGroupContext = createContext(false);
|
|
9
|
+
|
|
10
|
+
export const SHIMMER_TEXT_HEIGHT = DS_SPACING[800];
|
|
11
|
+
|
|
12
|
+
// Plain Shimmer blocks pulse between these two values. Translucent black (not an opaque grey) so the
|
|
13
|
+
// shimmer reads correctly as a wash over whatever surface it's placed on — light, dark, or coloured —
|
|
14
|
+
// the same convention DS uses for its other "works on any surface" overlays. Interpolating the colour
|
|
15
|
+
// itself (not a separate `opacity` style) keeps each independent block visually equivalent to a plain
|
|
16
|
+
// alpha wash.
|
|
17
|
+
const PULSE_LOW = 'rgba(0, 0, 0, 0.02)';
|
|
18
|
+
const PULSE_HIGH = 'rgba(0, 0, 0, 0.05)';
|
|
19
|
+
|
|
20
|
+
// One pulse cycle, shared by every Shimmer instance.
|
|
21
|
+
const PULSE_CYCLE_MS = DS_MOTION_LOOP_DURATION.pulse;
|
|
22
|
+
|
|
23
|
+
// A single module-level clock every Shimmer/Breathing instance reads from, instead of each starting
|
|
24
|
+
// its own timer at mount. Two independent per-instance timers would drift out of phase depending on
|
|
25
|
+
// the exact moment each mounted — sharing one clock means any shimmers visible together always pulse
|
|
26
|
+
// in lockstep. `useNativeDriver:false` is required because we interpolate `backgroundColor` (colour
|
|
27
|
+
// interpolation is JS-driven).
|
|
28
|
+
const sharedProgress = new Animated.Value(0);
|
|
29
|
+
let sharedProgressStarted = false;
|
|
30
|
+
function ensureSharedProgressStarted(): void {
|
|
31
|
+
if (sharedProgressStarted) return;
|
|
32
|
+
sharedProgressStarted = true;
|
|
33
|
+
Animated.loop(
|
|
34
|
+
Animated.timing(sharedProgress, {
|
|
35
|
+
toValue: 1,
|
|
36
|
+
duration: PULSE_CYCLE_MS,
|
|
37
|
+
easing: Easing.linear,
|
|
38
|
+
useNativeDriver: false,
|
|
39
|
+
}),
|
|
40
|
+
).start();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Shared animated primitive ────────────────────────────────────────────────
|
|
44
|
+
// A solid block that breathes between `low` and `high` on the shared progress value. Exported so any
|
|
45
|
+
// composite skeleton can reuse the same pulse (and stay in lockstep with plain Shimmers).
|
|
46
|
+
export function Breathing({
|
|
47
|
+
phase = 0,
|
|
48
|
+
low = PULSE_LOW,
|
|
49
|
+
high = PULSE_HIGH,
|
|
50
|
+
style,
|
|
51
|
+
accessibilityRole,
|
|
52
|
+
accessibilityLabel,
|
|
53
|
+
}: {
|
|
54
|
+
phase?: number;
|
|
55
|
+
/** Override the pulse's low/high colours. Defaults to the shared translucent pulse. */
|
|
56
|
+
low?: string;
|
|
57
|
+
high?: string;
|
|
58
|
+
style?: StyleProp<ViewStyle>;
|
|
59
|
+
accessibilityRole?: 'progressbar';
|
|
60
|
+
accessibilityLabel?: string;
|
|
61
|
+
}) {
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
ensureSharedProgressStarted();
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
// (progress + phase) % 1, then a triangle wave: high at 0, low at 0.5, high at 1 — equivalent to the
|
|
67
|
+
// cosine breathe, driven off the one shared linear clock so all instances stay phase-locked.
|
|
68
|
+
const backgroundColor = useMemo(
|
|
69
|
+
() =>
|
|
70
|
+
Animated.modulo(Animated.add(sharedProgress, phase), 1).interpolate({
|
|
71
|
+
inputRange: [0, 0.5, 1],
|
|
72
|
+
outputRange: [high, low, high],
|
|
73
|
+
}),
|
|
74
|
+
[phase, low, high],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<Animated.View
|
|
79
|
+
style={[style, { backgroundColor }]}
|
|
80
|
+
accessibilityRole={accessibilityRole}
|
|
81
|
+
accessibilityLabel={accessibilityLabel}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function dimensionsForVariant(
|
|
87
|
+
variant: ShimmerVariant,
|
|
88
|
+
width: ShimmerProps['width'],
|
|
89
|
+
height: ShimmerProps['height'],
|
|
90
|
+
size: number,
|
|
91
|
+
): ViewStyle {
|
|
92
|
+
switch (variant) {
|
|
93
|
+
case 'circle':
|
|
94
|
+
return { width: size, height: size, borderRadius: DS_RADIUS.round };
|
|
95
|
+
case 'container':
|
|
96
|
+
return { width: width ?? '100%', height: height ?? 80, borderRadius: DS_RADIUS.medium };
|
|
97
|
+
case 'text':
|
|
98
|
+
default:
|
|
99
|
+
return { width: width ?? '100%', height: height ?? SHIMMER_TEXT_HEIGHT, borderRadius: DS_RADIUS.small };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Shimmer ──────────────────────────────────────────────────────────────────
|
|
104
|
+
/**
|
|
105
|
+
* A single loading placeholder — see `ShimmerProps.variant` for what each shape stands in for.
|
|
106
|
+
* A real composite skeleton (e.g. a list row: avatar + a two-line label) is built by stacking
|
|
107
|
+
* multiple Shimmers of the right variant/count/dimensions, not by inventing one shape to cover
|
|
108
|
+
* everything — see the "Circle + text" example in the catalog for exactly this composition.
|
|
109
|
+
*/
|
|
110
|
+
export function Shimmer({ variant = 'text', width, height, size = 40, style }: ShimmerProps) {
|
|
111
|
+
// Inside a <SkeletonGroup>, the group owns the single "Loading" announcement — each block stays
|
|
112
|
+
// silent so a composite skeleton (avatar + two text lines = 3 blocks) doesn't make a screen
|
|
113
|
+
// reader read "Loading, Loading, Loading". A standalone Shimmer still announces on its own.
|
|
114
|
+
const insideGroup = useContext(SkeletonGroupContext);
|
|
115
|
+
return (
|
|
116
|
+
<Breathing
|
|
117
|
+
style={[dimensionsForVariant(variant, width, height, size), style]}
|
|
118
|
+
accessibilityRole={insideGroup ? undefined : 'progressbar'}
|
|
119
|
+
accessibilityLabel={insideGroup ? undefined : 'Loading'}
|
|
120
|
+
/>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── SkeletonGroup ──────────────────────────────────────────────────────────────
|
|
125
|
+
/**
|
|
126
|
+
* Wraps a composite skeleton (several stacked Shimmers standing in for one real element — e.g. a
|
|
127
|
+
* list row's avatar + two text lines) so assistive tech announces it as ONE "Loading" region
|
|
128
|
+
* instead of once per block. Provides {@link SkeletonGroupContext} so every descendant Shimmer drops
|
|
129
|
+
* its own announcement; the group itself carries the single `progressbar`/busy role. Pass your own
|
|
130
|
+
* layout via `style` (the group is a plain View, so `flexDirection`/`gap` land where you'd expect) —
|
|
131
|
+
* it adds no layout of its own.
|
|
132
|
+
*/
|
|
133
|
+
export function SkeletonGroup({
|
|
134
|
+
label = 'Loading',
|
|
135
|
+
style,
|
|
136
|
+
children,
|
|
137
|
+
}: {
|
|
138
|
+
/** Accessible name for the whole loading region. @default 'Loading' */
|
|
139
|
+
label?: string;
|
|
140
|
+
/** Layout for the group (flexDirection, gap, …) — it renders a plain View, so styles land where
|
|
141
|
+
* you'd expect. */
|
|
142
|
+
style?: StyleProp<ViewStyle>;
|
|
143
|
+
/** The Shimmers making up the skeleton. */
|
|
144
|
+
children: ReactNode;
|
|
145
|
+
}) {
|
|
146
|
+
return (
|
|
147
|
+
<SkeletonGroupContext.Provider value>
|
|
148
|
+
<View
|
|
149
|
+
style={style}
|
|
150
|
+
accessibilityRole="progressbar"
|
|
151
|
+
accessibilityLabel={label}
|
|
152
|
+
accessibilityState={{ busy: true }}
|
|
153
|
+
>
|
|
154
|
+
{children}
|
|
155
|
+
</View>
|
|
156
|
+
</SkeletonGroupContext.Provider>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
2
|
+
|
|
3
|
+
export type ShimmerVariant = 'text' | 'container' | 'circle';
|
|
4
|
+
|
|
5
|
+
export interface ShimmerProps {
|
|
6
|
+
/** Which shape this placeholder stands in for:
|
|
7
|
+
* - `'text'` (default) — **one line of text**. A real element with 3 lines of text gets 3
|
|
8
|
+
* stacked `text` Shimmers, not one tall one — a skeleton should mirror the real line count.
|
|
9
|
+
* When stacking 2+ lines, give the last one ~70% of the others' width — real text rarely fills
|
|
10
|
+
* every line edge-to-edge, and a shorter final line reads as "this is where the paragraph
|
|
11
|
+
* naturally ends," not a loading glitch.
|
|
12
|
+
* - `'circle'` — always a circular element (an Avatar, an icon-only Pill/Button, a status dot) —
|
|
13
|
+
* never used for anything that isn't actually round in its loaded state.
|
|
14
|
+
* - `'container'` — everything else: a Card, a Banner, an image, any non-text/non-circular UI
|
|
15
|
+
* element. Its `width`/`height` should match the real element it's replacing (a Card skeleton
|
|
16
|
+
* should be Card-shaped, not an arbitrary block), so the skeleton reserves the same layout
|
|
17
|
+
* space the loaded content will actually occupy — no jump when it swaps in. */
|
|
18
|
+
variant?: ShimmerVariant;
|
|
19
|
+
/** Width (text and container). Defaults to 100%. */
|
|
20
|
+
width?: number | `${number}%`;
|
|
21
|
+
/** Height (text and container). Defaults: text 16, container 80 — override to match the real
|
|
22
|
+
* element this stands in for. */
|
|
23
|
+
height?: number;
|
|
24
|
+
/** Diameter for `circle` variant. Default 40. */
|
|
25
|
+
size?: number;
|
|
26
|
+
style?: StyleProp<ViewStyle>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, StyleSheet } from 'react-native';
|
|
3
|
+
import { defineCatalogPage } from '@krapwoo/ds-viewer';
|
|
4
|
+
import { Shimmer, SkeletonGroup } from './Shimmer';
|
|
5
|
+
import { DS_SPACING } from '../../tokens';
|
|
6
|
+
|
|
7
|
+
const styles = StyleSheet.create({
|
|
8
|
+
shimmerLines: { flex: 1, gap: DS_SPACING[400] },
|
|
9
|
+
previewRow: { flexDirection: 'row', alignItems: 'center', gap: DS_SPACING[600] },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export default defineCatalogPage({
|
|
13
|
+
component: 'SkeletonGroup',
|
|
14
|
+
group: 'Sub-Parts',
|
|
15
|
+
description: 'An accessibility wrapper for a composite skeleton — several Shimmers standing in for one real element (e.g. a list row: an avatar + a two-line label). It announces the whole thing as one "Loading" region and silences the individual blocks, so a screen reader says "Loading" once instead of once per Shimmer. Adds no layout of its own — pass your own flexDirection/gap via style.',
|
|
16
|
+
whenToUse: 'Wrap 2+ Shimmers that together stand in for one element. A single lone Shimmer already announces on its own and needs no wrapper.',
|
|
17
|
+
a11y: 'Carries accessibilityRole="progressbar" + accessibilityLabel (default "Loading") + accessibilityState={{ busy: true }} for the whole region; provides a context that makes every descendant Shimmer drop its own announcement. Net effect: one "Loading" announcement per skeleton, not one per block.',
|
|
18
|
+
// No visual variants/states — it's a transparent a11y wrapper. The one example shows the
|
|
19
|
+
// composite it wraps; Props + Accessibility document its contract.
|
|
20
|
+
hide: { states: true },
|
|
21
|
+
variants: {
|
|
22
|
+
itemsFill: true,
|
|
23
|
+
items: [
|
|
24
|
+
{
|
|
25
|
+
key: 'composite',
|
|
26
|
+
name: 'Wrapping a composite skeleton',
|
|
27
|
+
node: (
|
|
28
|
+
<SkeletonGroup style={styles.previewRow}>
|
|
29
|
+
<Shimmer variant="circle" size={40} />
|
|
30
|
+
<View style={styles.shimmerLines}>
|
|
31
|
+
<Shimmer variant="text" width={160} />
|
|
32
|
+
<Shimmer variant="text" width={112} />
|
|
33
|
+
</View>
|
|
34
|
+
</SkeletonGroup>
|
|
35
|
+
),
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Text, StyleSheet } from 'react-native';
|
|
3
|
+
import { defineCatalogPage } from '@krapwoo/ds-viewer';
|
|
4
|
+
import { Surface } from './Surface';
|
|
5
|
+
import { DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY } from '../../tokens';
|
|
6
|
+
|
|
7
|
+
const styles = StyleSheet.create({
|
|
8
|
+
surfaceDemo: { height: 96, justifyContent: 'center', alignItems: 'center', padding: DS_SPACING[800] },
|
|
9
|
+
surfaceText: { ...DS_TYPOGRAPHY.bodyMd, color: DS_SEMANTIC.text.regular },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export default defineCatalogPage({
|
|
13
|
+
component: 'Surface',
|
|
14
|
+
group: 'Surfaces',
|
|
15
|
+
description:
|
|
16
|
+
"Declares \"this subtree's background is tone\" — renders a plain View filled with the matching token (surface.white or surface.main) and provides that tone to descendants via context, so a tone-aware component inside (e.g. FieldContainer/InputField) can automatically pick a fill that contrasts with it instead of assuming white.",
|
|
17
|
+
whenToUse:
|
|
18
|
+
"Wrap a screen (or any region) wherever its background isn't the default white — a page with a muted/grey background, for instance — so anything inside that needs to contrast with its background picks the right fill automatically.",
|
|
19
|
+
variants: {
|
|
20
|
+
itemsFill: true,
|
|
21
|
+
items: [
|
|
22
|
+
{
|
|
23
|
+
key: 'white',
|
|
24
|
+
name: 'White',
|
|
25
|
+
props: { tone: 'white' },
|
|
26
|
+
node: (
|
|
27
|
+
<Surface tone="white" style={styles.surfaceDemo}>
|
|
28
|
+
<Text style={styles.surfaceText}>surface.white</Text>
|
|
29
|
+
</Surface>
|
|
30
|
+
),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: 'muted',
|
|
34
|
+
name: 'Muted',
|
|
35
|
+
props: { tone: 'muted' },
|
|
36
|
+
node: (
|
|
37
|
+
<Surface tone="muted" style={styles.surfaceDemo}>
|
|
38
|
+
<Text style={styles.surfaceText}>surface.main</Text>
|
|
39
|
+
</Surface>
|
|
40
|
+
),
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React, { createContext, useContext, type ReactNode } from 'react';
|
|
2
|
+
import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { DS_SEMANTIC } from '../../tokens';
|
|
4
|
+
|
|
5
|
+
export type SurfaceTone = 'white' | 'muted';
|
|
6
|
+
|
|
7
|
+
// Defaults to 'white' — a screen with no explicit <Surface> ancestor is assumed to sit on a white
|
|
8
|
+
// background, the common case (matches e.g. Card's own default fill).
|
|
9
|
+
const SurfaceToneContext = createContext<SurfaceTone>('white');
|
|
10
|
+
|
|
11
|
+
/** What background tone the nearest ancestor `<Surface>` declared (or 'white' if there isn't one).
|
|
12
|
+
* Components that need to contrast with whatever's actually behind them — FieldContainer/
|
|
13
|
+
* InputField today — read this instead of assuming one fixed background. */
|
|
14
|
+
export function useSurfaceTone(): SurfaceTone {
|
|
15
|
+
return useContext(SurfaceToneContext);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SurfaceProps {
|
|
19
|
+
/** Which background this surface renders. Descendants read this via `useSurfaceTone()` to
|
|
20
|
+
* automatically pick a fill that contrasts with it (e.g. a field goes white on a muted page,
|
|
21
|
+
* recessed on a white one) instead of needing to be told individually. */
|
|
22
|
+
tone: SurfaceTone;
|
|
23
|
+
style?: StyleProp<ViewStyle>;
|
|
24
|
+
children?: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Declares "this subtree's background is `tone`" — renders a plain View filled with the matching
|
|
29
|
+
* token (`surface.white` or `surface.main`) and provides that tone to descendants via context.
|
|
30
|
+
* Wrap a screen (or any region) in this wherever its background isn't the default white — a page
|
|
31
|
+
* with a muted/grey background, for instance — so anything inside that needs to contrast with its
|
|
32
|
+
* background picks the right fill automatically rather than assuming white.
|
|
33
|
+
*/
|
|
34
|
+
export function Surface({ tone, style, children }: SurfaceProps) {
|
|
35
|
+
return (
|
|
36
|
+
<SurfaceToneContext.Provider value={tone}>
|
|
37
|
+
<View style={[tone === 'white' ? styles.white : styles.muted, style]}>{children}</View>
|
|
38
|
+
</SurfaceToneContext.Provider>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const styles = StyleSheet.create({
|
|
43
|
+
white: { backgroundColor: DS_SEMANTIC.surface.white },
|
|
44
|
+
muted: { backgroundColor: DS_SEMANTIC.surface.main },
|
|
45
|
+
});
|