@krapwoo/ds-viewer 0.1.1 → 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.
Files changed (159) hide show
  1. package/README.md +100 -78
  2. package/dist/cli/config.js +26 -1
  3. package/dist/cli/dev.js +50 -5
  4. package/dist/cli/glob.js +8 -0
  5. package/dist/cli/init.js +148 -18
  6. package/dist/cli/main.js +66 -4
  7. package/dist/cli/props.js +166 -18
  8. package/dist/cli/sync.js +17 -1
  9. package/dist/cli/workspace.js +49 -21
  10. package/native/catalog/CatalogShell.tsx +6 -1
  11. package/native/catalog/CatalogSidebar.tsx +98 -5
  12. package/native/catalog/TokenRow.tsx +3 -2
  13. package/native/catalog/index.ts +6 -5
  14. package/native/catalog/logoLayout.ts +12 -0
  15. package/native/catalog/pageApi.ts +10 -1
  16. package/native/catalog/tokens.ts +5 -0
  17. package/package.json +8 -6
  18. package/starter-kit/WHEN_TO_USE.md +124 -0
  19. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +23 -0
  20. package/starter-kit/components/AnimatedChevron/AnimatedChevron.tsx +46 -0
  21. package/starter-kit/components/AnimatedChevron/index.ts +2 -0
  22. package/starter-kit/components/Avatar/Avatar.catalog.tsx +72 -0
  23. package/starter-kit/components/Avatar/Avatar.tsx +74 -0
  24. package/starter-kit/components/Avatar/index.ts +2 -0
  25. package/starter-kit/components/Badge/Badge.catalog.tsx +96 -0
  26. package/starter-kit/components/Badge/Badge.tsx +56 -0
  27. package/starter-kit/components/Badge/Badge.types.ts +18 -0
  28. package/starter-kit/components/Badge/index.ts +2 -0
  29. package/starter-kit/components/Banner/Banner.catalog.tsx +87 -0
  30. package/starter-kit/components/Banner/Banner.tsx +257 -0
  31. package/starter-kit/components/Banner/Banner.types.ts +38 -0
  32. package/starter-kit/components/Banner/index.ts +2 -0
  33. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +77 -0
  34. package/starter-kit/components/BottomSheet/BottomSheet.tsx +151 -0
  35. package/starter-kit/components/BottomSheet/index.ts +2 -0
  36. package/starter-kit/components/Button/Button.catalog.tsx +113 -0
  37. package/starter-kit/components/Button/Button.tsx +173 -0
  38. package/starter-kit/components/Button/Button.types.ts +52 -0
  39. package/starter-kit/components/Button/index.ts +2 -0
  40. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +39 -0
  41. package/starter-kit/components/ButtonGroup/ButtonGroup.tsx +84 -0
  42. package/starter-kit/components/ButtonGroup/index.ts +2 -0
  43. package/starter-kit/components/Card/Card.catalog.tsx +68 -0
  44. package/starter-kit/components/Card/Card.tsx +133 -0
  45. package/starter-kit/components/Card/index.ts +2 -0
  46. package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +28 -0
  47. package/starter-kit/components/Checkbox/Checkbox.tsx +104 -0
  48. package/starter-kit/components/Checkbox/index.ts +2 -0
  49. package/starter-kit/components/Dialog/Dialog.catalog.tsx +41 -0
  50. package/starter-kit/components/Dialog/Dialog.tsx +82 -0
  51. package/starter-kit/components/Dialog/index.ts +2 -0
  52. package/starter-kit/components/Divider/Divider.catalog.tsx +35 -0
  53. package/starter-kit/components/Divider/Divider.tsx +20 -0
  54. package/starter-kit/components/Divider/index.ts +2 -0
  55. package/starter-kit/components/Dock/Dock.catalog.tsx +71 -0
  56. package/starter-kit/components/Dock/Dock.tsx +58 -0
  57. package/starter-kit/components/Dock/index.ts +2 -0
  58. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +69 -0
  59. package/starter-kit/components/Dropdown/Dropdown.tsx +162 -0
  60. package/starter-kit/components/Dropdown/index.ts +2 -0
  61. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +67 -0
  62. package/starter-kit/components/EmptyState/EmptyState.tsx +88 -0
  63. package/starter-kit/components/EmptyState/index.ts +2 -0
  64. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +76 -0
  65. package/starter-kit/components/FieldContainer/FieldContainer.tsx +99 -0
  66. package/starter-kit/components/FieldContainer/FieldContainer.types.ts +27 -0
  67. package/starter-kit/components/FieldContainer/index.ts +2 -0
  68. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +18 -0
  69. package/starter-kit/components/InputClearButton/InputClearButton.tsx +63 -0
  70. package/starter-kit/components/InputClearButton/index.ts +2 -0
  71. package/starter-kit/components/InputField/InputField.catalog.tsx +76 -0
  72. package/starter-kit/components/InputField/InputField.tsx +418 -0
  73. package/starter-kit/components/InputField/index.ts +2 -0
  74. package/starter-kit/components/List/List.catalog.tsx +30 -0
  75. package/starter-kit/components/List/List.tsx +36 -0
  76. package/starter-kit/components/List/index.ts +2 -0
  77. package/starter-kit/components/ListItem/ListItem.catalog.tsx +89 -0
  78. package/starter-kit/components/ListItem/ListItem.tsx +165 -0
  79. package/starter-kit/components/ListItem/index.ts +2 -0
  80. package/starter-kit/components/Loading/Loading.catalog.tsx +34 -0
  81. package/starter-kit/components/Loading/Loading.tsx +121 -0
  82. package/starter-kit/components/Loading/index.ts +2 -0
  83. package/starter-kit/components/Pill/Pill.catalog.tsx +93 -0
  84. package/starter-kit/components/Pill/Pill.tsx +181 -0
  85. package/starter-kit/components/Pill/Pill.types.ts +30 -0
  86. package/starter-kit/components/Pill/index.ts +2 -0
  87. package/starter-kit/components/PillRow/PillRow.catalog.tsx +91 -0
  88. package/starter-kit/components/PillRow/PillRow.tsx +140 -0
  89. package/starter-kit/components/PillRow/PillRow.types.ts +24 -0
  90. package/starter-kit/components/PillRow/index.ts +2 -0
  91. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +22 -0
  92. package/starter-kit/components/ProgressDots/ProgressDots.tsx +42 -0
  93. package/starter-kit/components/ProgressDots/index.ts +2 -0
  94. package/starter-kit/components/Radio/Radio.catalog.tsx +38 -0
  95. package/starter-kit/components/Radio/Radio.tsx +114 -0
  96. package/starter-kit/components/Radio/index.ts +2 -0
  97. package/starter-kit/components/Radio/usePressScale.ts +33 -0
  98. package/starter-kit/components/SearchField/SearchField.catalog.tsx +28 -0
  99. package/starter-kit/components/SearchField/SearchField.tsx +98 -0
  100. package/starter-kit/components/SearchField/index.ts +2 -0
  101. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +72 -0
  102. package/starter-kit/components/SectionHeader/SectionHeader.tsx +139 -0
  103. package/starter-kit/components/SectionHeader/index.ts +2 -0
  104. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +31 -0
  105. package/starter-kit/components/SegmentedToggle/SegmentedToggle.tsx +163 -0
  106. package/starter-kit/components/SegmentedToggle/index.ts +2 -0
  107. package/starter-kit/components/SegmentedToggle/useSlideAnim.ts +29 -0
  108. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +29 -0
  109. package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +89 -0
  110. package/starter-kit/components/Shimmer/Shimmer.tsx +158 -0
  111. package/starter-kit/components/Shimmer/Shimmer.types.ts +27 -0
  112. package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +39 -0
  113. package/starter-kit/components/Shimmer/index.ts +2 -0
  114. package/starter-kit/components/Surface/Surface.catalog.tsx +44 -0
  115. package/starter-kit/components/Surface/Surface.tsx +45 -0
  116. package/starter-kit/components/Surface/index.ts +2 -0
  117. package/starter-kit/components/Switch/Switch.catalog.tsx +33 -0
  118. package/starter-kit/components/Switch/Switch.tsx +129 -0
  119. package/starter-kit/components/Switch/index.ts +2 -0
  120. package/starter-kit/components/TextArea/TextArea.catalog.tsx +29 -0
  121. package/starter-kit/components/TextArea/TextArea.tsx +99 -0
  122. package/starter-kit/components/TextArea/index.ts +2 -0
  123. package/starter-kit/components/Toast/Toast.catalog.tsx +61 -0
  124. package/starter-kit/components/Toast/Toast.tsx +144 -0
  125. package/starter-kit/components/Toast/index.ts +2 -0
  126. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +121 -0
  127. package/starter-kit/components/Tooltip/Tooltip.tsx +195 -0
  128. package/starter-kit/components/Tooltip/index.ts +2 -0
  129. package/starter-kit/components/TopNav/TopNav.catalog.tsx +71 -0
  130. package/starter-kit/components/TopNav/TopNav.tsx +77 -0
  131. package/starter-kit/components/TopNav/index.ts +2 -0
  132. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +30 -0
  133. package/starter-kit/components/UnderlineTabs/UnderlineTabs.tsx +156 -0
  134. package/starter-kit/components/UnderlineTabs/index.ts +2 -0
  135. package/starter-kit/components/index.ts +36 -0
  136. package/starter-kit/components/statusColors.ts +27 -0
  137. package/starter-kit/icons/Icon.native.tsx +90 -0
  138. package/starter-kit/icons/index.ts +10 -0
  139. package/starter-kit/icons/paths.ts +623 -0
  140. package/starter-kit/icons/types.ts +69 -0
  141. package/starter-kit/pages/Colors.catalog.tsx +55 -0
  142. package/starter-kit/pages/Font.catalog.tsx +45 -0
  143. package/starter-kit/pages/Icons.catalog.tsx +51 -0
  144. package/starter-kit/pages/Manifest.catalog.tsx +24 -0
  145. package/starter-kit/pages/Motion.catalog.tsx +101 -0
  146. package/starter-kit/pages/Radius.catalog.tsx +35 -0
  147. package/starter-kit/pages/ReportIssue.catalog.tsx +148 -0
  148. package/starter-kit/pages/SavedTrips.catalog.tsx +247 -0
  149. package/starter-kit/pages/Shadow.catalog.tsx +34 -0
  150. package/starter-kit/pages/Spacing.catalog.tsx +12 -0
  151. package/starter-kit/pages/TripPlannerForm.catalog.tsx +48 -0
  152. package/starter-kit/pages/Typography.catalog.tsx +17 -0
  153. package/starter-kit/tokens/index.ts +50 -0
  154. package/starter-kit/tokens/motion.ts +90 -0
  155. package/starter-kit/tokens/palette.ts +111 -0
  156. package/starter-kit/tokens/scales.ts +106 -0
  157. package/starter-kit/tokens/semantic.ts +98 -0
  158. package/starter-kit/tokens/shadow.ts +36 -0
  159. package/starter-kit/tokens/typography.ts +72 -0
@@ -0,0 +1,18 @@
1
+ import type { StyleProp, ViewStyle } from 'react-native';
2
+ import type { IconName } from '../../icons';
3
+
4
+ export type BadgeVariant = 'neutral' | 'info' | 'positive' | 'warning' | 'negative';
5
+
6
+ export interface BadgeProps {
7
+ /** Semantic color scheme. */
8
+ variant?: BadgeVariant;
9
+ /** Label text. Omit (with a single icon) for an icon-only chip. */
10
+ label?: string;
11
+ /** Icon before the label. */
12
+ leadingIcon?: IconName;
13
+ /** Icon after the label. */
14
+ trailingIcon?: IconName;
15
+ /** Accessible name — required for an icon-only badge (no `label`) so it can be announced. */
16
+ accessibilityLabel?: string;
17
+ style?: StyleProp<ViewStyle>;
18
+ }
@@ -0,0 +1,2 @@
1
+ export { Badge } from './Badge';
2
+ export type { BadgeProps, BadgeVariant } from './Badge.types';
@@ -0,0 +1,87 @@
1
+ import React from 'react';
2
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
+ import { Banner } from './Banner';
4
+
5
+ export default defineCatalogPage({
6
+ component: 'Banner',
7
+ group: 'Surfaces',
8
+ description: 'An inline callout for status/announcements — five semantic variants, optional collapsible body, inline link, and action button.',
9
+ whenToUse: "Persistent and in-flow, describing a standing condition about the screen's content. For a transient, self-contained event notification, use Toast instead.",
10
+ a11y: 'When onPress/action is set the header/button are Pressables; the collapsible header toggles the description with a chevron affordance.',
11
+ variants: {
12
+ itemsFill: true,
13
+ items: [
14
+ { key: 'neutral', name: 'Neutral', props: { variant: 'neutral' }, node: <Banner variant="neutral" title="Schedule notice" description="Holiday schedule in effect Monday." /> },
15
+ { key: 'info', name: 'Info', props: { variant: 'info' }, node: <Banner variant="info" title="Weekend service change" description="The 6 runs express in both directions this weekend." /> },
16
+ { key: 'positive', name: 'Positive', props: { variant: 'positive' }, node: <Banner variant="positive" title="Service restored" description="All lines are running on a normal schedule." /> },
17
+ { key: 'warning', name: 'Warning', props: { variant: 'warning' }, node: <Banner variant="warning" title="Delays" description="Signal problems near 14 St." /> },
18
+ { key: 'negative', name: 'Negative', props: { variant: 'negative' }, node: <Banner variant="negative" title="Line suspended" description="No service between 96 St and 137 St until further notice." /> },
19
+ ],
20
+ },
21
+ states: {
22
+ itemsFill: true,
23
+ items: [
24
+ {
25
+ key: 'tappable',
26
+ name: 'Tappable',
27
+ node: (
28
+ <Banner
29
+ variant="neutral"
30
+ title="Alert preferences"
31
+ description="Tap to manage which alerts you receive."
32
+ onPress={() => {}}
33
+ />
34
+ ),
35
+ },
36
+ {
37
+ key: 'collapsible-expanded',
38
+ name: 'Collapsible (expanded)',
39
+ node: (
40
+ <Banner
41
+ variant="neutral"
42
+ title="Trip details"
43
+ description="Board at the front car for a faster transfer at Union Sq."
44
+ collapsible
45
+ />
46
+ ),
47
+ },
48
+ {
49
+ key: 'collapsible-collapsed',
50
+ name: 'Collapsible (collapsed)',
51
+ node: (
52
+ <Banner
53
+ variant="neutral"
54
+ title="Trip details"
55
+ description="Board at the front car for a faster transfer at Union Sq."
56
+ collapsible
57
+ defaultExpanded={false}
58
+ />
59
+ ),
60
+ },
61
+ {
62
+ key: 'action',
63
+ name: 'With action',
64
+ node: (
65
+ <Banner
66
+ variant="info"
67
+ title="App update available"
68
+ description="Version 4.2 adds live bus tracking."
69
+ action={{ label: 'Update', onPress: () => {} }}
70
+ />
71
+ ),
72
+ },
73
+ {
74
+ key: 'link',
75
+ name: 'With link',
76
+ node: (
77
+ <Banner
78
+ variant="neutral"
79
+ title="Fare increase"
80
+ description="New fares start March 1."
81
+ link={{ label: 'Learn more', onPress: () => {} }}
82
+ />
83
+ ),
84
+ },
85
+ ],
86
+ },
87
+ });
@@ -0,0 +1,257 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { View, Text, Pressable, Animated, Easing, StyleSheet, type LayoutChangeEvent } from 'react-native';
3
+ import { DS_SEMANTIC, DS_RADIUS, DS_SPACING, DS_TYPOGRAPHY, DS_ICON_SIZE, DS_MOTION_DURATION, DS_MOTION_EASING } from '../../tokens';
4
+ import { Icon } from '../../icons/Icon.native';
5
+ import type { IconName } from '../../icons';
6
+ import { AnimatedChevron } from '../AnimatedChevron';
7
+ import { STATUS_BG, STATUS_FG } from '../statusColors';
8
+ import type { BannerProps, BannerVariant } from './Banner.types';
9
+
10
+ // Shared with Badge (see ../statusColors) so a banner reads as the same status identity as a
11
+ // badge of the same variant — one map, matched by construction.
12
+
13
+ const BANNER_ICONS: Record<BannerVariant, IconName> = {
14
+ neutral: 'info-circle',
15
+ info: 'info-circle',
16
+ positive: 'circle-check',
17
+ warning: 'triangle-alert',
18
+ negative: 'alert-circle',
19
+ };
20
+
21
+ const COLLAPSE_ANIM_MS = DS_MOTION_DURATION.base;
22
+
23
+ /**
24
+ * A status callout in two layouts, chosen by props:
25
+ * • collapsible callout — the whole card toggles the description open/closed;
26
+ * • standard callout — leading icon + title, optional description/link/action.
27
+ */
28
+ export function Banner({
29
+ variant = 'warning',
30
+ title = 'Banner title',
31
+ description = 'Description text goes here',
32
+ collapsible = false,
33
+ defaultExpanded = true,
34
+ backgroundColor,
35
+ onPress,
36
+ trailingIcon,
37
+ textColor,
38
+ icon,
39
+ link,
40
+ action,
41
+ style,
42
+ }: BannerProps) {
43
+ const [expanded, setExpanded] = useState(defaultExpanded);
44
+ // Real layout height + opacity via the core `Animated` API (`useNativeDriver: false`) — not
45
+ // `LayoutAnimation`, which is unreliable on the New Architecture (Fabric) and a total no-op on
46
+ // web, so the collapse used to just snap instantly there. Same measure-once-then-animate
47
+ // technique used elsewhere in this DS: render in normal flow only on the very first pass while
48
+ // already expanded (shows instantly, no flash, and captures the real height); every other pass —
49
+ // including the very first while starting collapsed — positions the content absolutely so it can
50
+ // self-measure without affecting layout, then animates between 0 and that measured height.
51
+ const [measuredHeight, setMeasuredHeight] = useState(0);
52
+ const collapseAnim = useRef(new Animated.Value(defaultExpanded ? 1 : 0)).current;
53
+
54
+ useEffect(() => {
55
+ const anim = Animated.timing(collapseAnim, {
56
+ toValue: expanded ? 1 : 0,
57
+ duration: COLLAPSE_ANIM_MS,
58
+ // `standard` — an in-place expand/collapse (a toggle), per DS_MOTION_EASING_USE.
59
+ easing: Easing.bezier(...DS_MOTION_EASING.standard),
60
+ useNativeDriver: false,
61
+ });
62
+ anim.start();
63
+ return () => anim.stop();
64
+ }, [expanded, collapseAnim]);
65
+
66
+ const bg = backgroundColor ?? STATUS_BG[variant];
67
+ const contentTextColor = textColor ?? STATUS_FG[variant];
68
+ const iconName = icon ?? BANNER_ICONS[variant];
69
+
70
+ // ── Collapsible callout: the whole banner toggles the description ─────────────
71
+ if (collapsible) {
72
+ const toggle = () => setExpanded(e => !e);
73
+ const flowMeasure = measuredHeight === 0 && expanded;
74
+ const onDescriptionLayout = (e: LayoutChangeEvent) => {
75
+ const h = e.nativeEvent.layout.height;
76
+ if (h > 0 && h !== measuredHeight) setMeasuredHeight(h);
77
+ };
78
+ return (
79
+ <Pressable
80
+ style={[styles.calloutContainer, { backgroundColor: bg }, style]}
81
+ onPress={toggle}
82
+ accessibilityRole="button"
83
+ accessibilityState={{ expanded }}
84
+ >
85
+ {({ pressed }) => (
86
+ <>
87
+ {pressed && <View style={styles.pressOverlay} pointerEvents="none" />}
88
+ <View style={styles.headerRow}>
89
+ <Icon name={iconName} size={DS_ICON_SIZE.xs} color={contentTextColor} />
90
+ <Text style={[styles.title, styles.titleFlex, { color: contentTextColor, marginBottom: 0 }]}>
91
+ {title}
92
+ </Text>
93
+ <AnimatedChevron expanded={expanded} size={DS_ICON_SIZE.xs} color={contentTextColor} />
94
+ </View>
95
+ {!!description && (
96
+ <Animated.View
97
+ style={[
98
+ styles.collapseClip,
99
+ measuredHeight === 0
100
+ ? expanded
101
+ ? null
102
+ : styles.collapseHidden
103
+ : { height: Animated.multiply(collapseAnim, measuredHeight), opacity: collapseAnim },
104
+ ]}
105
+ >
106
+ <View style={flowMeasure ? undefined : styles.collapseAbsolute} onLayout={onDescriptionLayout}>
107
+ <Text style={[styles.description, styles.descriptionPad, { color: contentTextColor }]}>
108
+ {description}
109
+ </Text>
110
+ </View>
111
+ </Animated.View>
112
+ )}
113
+ </>
114
+ )}
115
+ </Pressable>
116
+ );
117
+ }
118
+
119
+ // ── Standard callout (title + description) ────────────────────────────────────
120
+ const body = (pressed: boolean) => (
121
+ <>
122
+ {onPress && pressed && <View style={styles.pressOverlay} pointerEvents="none" />}
123
+ <View style={styles.headerRow}>
124
+ <Icon name={iconName} size={DS_ICON_SIZE.xs} color={contentTextColor} />
125
+ <Text style={[styles.title, styles.titleFlex, { color: contentTextColor, marginBottom: 0 }]}>
126
+ {title}
127
+ </Text>
128
+ {trailingIcon ? <Icon name={trailingIcon} size={DS_ICON_SIZE.xs} color={contentTextColor} /> : null}
129
+ </View>
130
+ {description || link ? (
131
+ <Text style={[styles.description, styles.descriptionPad, { color: contentTextColor }]}>
132
+ {description ?? ''}
133
+ {description && link ? ' ' : null}
134
+ {link ? (
135
+ <Text style={styles.linkText} onPress={link.onPress} accessibilityRole="link">
136
+ {link.label}
137
+ </Text>
138
+ ) : null}
139
+ </Text>
140
+ ) : null}
141
+ {action ? (
142
+ <View style={[styles.actionRow, { paddingHorizontal: DS_SPACING[800] }]}>
143
+ <Pressable
144
+ style={({ pressed }) => [styles.actionButton, pressed && styles.actionButtonPressed]}
145
+ // Pads the compact text button's visual ~26px height out to the 44pt touch-target minimum.
146
+ hitSlop={10}
147
+ onPress={action.onPress}
148
+ accessibilityRole="button"
149
+ accessibilityLabel={action.label}
150
+ >
151
+ <Text style={[styles.actionLabel, { color: contentTextColor }]}>{action.label}</Text>
152
+ </Pressable>
153
+ </View>
154
+ ) : null}
155
+ </>
156
+ );
157
+
158
+ if (onPress) {
159
+ return (
160
+ <Pressable
161
+ style={[styles.calloutContainer, { backgroundColor: bg }, style]}
162
+ onPress={onPress}
163
+ accessibilityRole="button"
164
+ accessibilityLabel={title}
165
+ >
166
+ {({ pressed }) => body(pressed)}
167
+ </Pressable>
168
+ );
169
+ }
170
+
171
+ return (
172
+ <View style={[styles.calloutContainer, { backgroundColor: bg }, style]}>{body(false)}</View>
173
+ );
174
+ }
175
+
176
+ const styles = StyleSheet.create({
177
+ actionRow: {
178
+ flexDirection: 'row',
179
+ justifyContent: 'flex-end',
180
+ marginTop: DS_SPACING[400],
181
+ },
182
+ actionButton: {
183
+ borderRadius: DS_RADIUS.xs,
184
+ paddingVertical: DS_SPACING[200],
185
+ paddingHorizontal: DS_SPACING[600],
186
+ },
187
+ actionButtonPressed: {
188
+ backgroundColor: DS_SEMANTIC.interaction.pressed,
189
+ },
190
+ actionLabel: {
191
+ ...DS_TYPOGRAPHY.labelSm,
192
+ },
193
+ // Was undistinguished from the surrounding description text (just inherited its color via
194
+ // nested-Text color inheritance) — underline alone with no color difference is easy to miss at a
195
+ // glance and gives nothing to fall back on if the underline is ever dropped.
196
+ linkText: {
197
+ textDecorationLine: 'underline',
198
+ color: DS_SEMANTIC.text.link,
199
+ },
200
+ // Callout: the header carries the top + horizontal padding so the pressed state fills it; the
201
+ // bottom padding lives on the container (constant) so nothing jumps when toggling — only the
202
+ // description mounts/unmounts. overflow:hidden keeps the rounded corners.
203
+ calloutContainer: {
204
+ borderRadius: DS_RADIUS.medium,
205
+ width: '100%',
206
+ overflow: 'hidden',
207
+ paddingBottom: DS_SPACING[800],
208
+ },
209
+ headerRow: {
210
+ flexDirection: 'row',
211
+ alignItems: 'center',
212
+ justifyContent: 'space-between',
213
+ gap: DS_SPACING[400],
214
+ paddingTop: DS_SPACING[800],
215
+ paddingHorizontal: DS_SPACING[800],
216
+ },
217
+ title: {
218
+ ...DS_TYPOGRAPHY.labelSm,
219
+ color: DS_SEMANTIC.text.regular,
220
+ marginBottom: DS_SPACING[200],
221
+ },
222
+ titleFlex: {
223
+ flex: 1,
224
+ },
225
+ description: {
226
+ ...DS_TYPOGRAPHY.bodySm,
227
+ color: DS_SEMANTIC.text.regular,
228
+ },
229
+ descriptionPad: {
230
+ // Start-aligns with the title: headerRow padding (800) + icon + gap (400). Logical start/end
231
+ // (not left/right) so the indent follows the icon+title when the layout mirrors in RTL.
232
+ paddingStart: DS_SPACING[800] + DS_ICON_SIZE.xs + DS_SPACING[400],
233
+ paddingEnd: DS_SPACING[800],
234
+ paddingTop: DS_SPACING[200],
235
+ },
236
+ // Translucent pressed overlay — sits above the banner bg so the tinted bg shows through.
237
+ pressOverlay: {
238
+ ...StyleSheet.absoluteFillObject,
239
+ backgroundColor: DS_SEMANTIC.interaction.pressed,
240
+ borderRadius: DS_RADIUS.medium,
241
+ },
242
+ // Collapsible description — clips the animated height; the child is pinned absolute (except on
243
+ // its very first in-flow measuring pass) so shrinking the parent never re-measures/corrupts it.
244
+ collapseClip: {
245
+ overflow: 'hidden',
246
+ width: '100%',
247
+ },
248
+ collapseHidden: {
249
+ height: 0,
250
+ },
251
+ collapseAbsolute: {
252
+ position: 'absolute',
253
+ left: 0,
254
+ right: 0,
255
+ top: 0,
256
+ },
257
+ });
@@ -0,0 +1,38 @@
1
+ import type { StyleProp, ViewStyle } from 'react-native';
2
+ import type { IconName } from '../../icons';
3
+
4
+ export type BannerVariant = 'neutral' | 'info' | 'positive' | 'warning' | 'negative';
5
+
6
+ export interface BannerProps {
7
+ /** Semantic color scheme. */
8
+ variant?: BannerVariant;
9
+ /** Header text. */
10
+ title?: string;
11
+ /** Body text. */
12
+ description?: string;
13
+ /** When true, the header row (title + chevron) toggles the description open/closed. */
14
+ collapsible?: boolean;
15
+ /** Initial expanded state when collapsible. Defaults to true (description shown). */
16
+ defaultExpanded?: boolean;
17
+ /** Override the variant background color. */
18
+ backgroundColor?: string;
19
+ /** Makes the whole banner tappable, with a pressed state — the tap target is the whole card. */
20
+ onPress?: () => void;
21
+ /** Trailing icon in the header, right-aligned (e.g. 'chevron-right'). */
22
+ trailingIcon?: IconName;
23
+ /** Override the icon/text color (defaults to the variant's emphasis color). */
24
+ textColor?: string;
25
+ /** Override the leading icon for the standard callout layout. */
26
+ icon?: IconName;
27
+ /** Inline tappable link appended to the description text. */
28
+ link?: {
29
+ label: string;
30
+ onPress: () => void;
31
+ };
32
+ /** Action button rendered below the description, right-aligned. */
33
+ action?: {
34
+ label: string;
35
+ onPress: () => void;
36
+ };
37
+ style?: StyleProp<ViewStyle>;
38
+ }
@@ -0,0 +1,2 @@
1
+ export { Banner } from './Banner';
2
+ export type { BannerProps, BannerVariant } from './Banner.types';
@@ -0,0 +1,77 @@
1
+ import React, { useState } from 'react';
2
+ import { View, Text, StyleSheet } from 'react-native';
3
+ import { defineCatalogPage, PhoneFrame } from '@krapwoo/ds-viewer';
4
+ import { Button } from '../Button';
5
+ import { Dock } from '../Dock';
6
+ import { TopNav } from '../TopNav';
7
+ import { BottomSheet } from './BottomSheet';
8
+ import { DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY } from '../../tokens';
9
+
10
+ const styles = StyleSheet.create({
11
+ // BottomSheet/Dropdown demos render inside the shared `PhoneFrame` (./PhoneFrame.tsx) so their
12
+ // absolute overlays stay contained instead of covering the whole catalog page. No padding here —
13
+ // BottomSheet's own content area already supplies the standard 16px.
14
+ sheetContent: {},
15
+ cardTitle: { ...DS_TYPOGRAPHY.labelMd, color: DS_SEMANTIC.text.regular },
16
+ cardBody: { ...DS_TYPOGRAPHY.bodySm, color: DS_SEMANTIC.text.muted, marginTop: DS_SPACING[200] },
17
+ });
18
+
19
+ function BottomSheetDemo() {
20
+ const [visible, setVisible] = useState(false);
21
+ return (
22
+ <PhoneFrame>
23
+ {!visible && <Button label="Open sheet" onPress={() => setVisible(true)} />}
24
+ <BottomSheet
25
+ visible={visible}
26
+ onDismiss={() => setVisible(false)}
27
+ header={
28
+ <TopNav
29
+ title="Trip details"
30
+ trailing={
31
+ <Button
32
+ variant="secondary"
33
+ size="small"
34
+ showIcon
35
+ showLabel={false}
36
+ iconName="clear"
37
+ accessibilityLabel="Close"
38
+ onPress={() => setVisible(false)}
39
+ />
40
+ }
41
+ />
42
+ }
43
+ footer={
44
+ <Dock>
45
+ <Button label="Confirm" onPress={() => setVisible(false)} />
46
+ </Dock>
47
+ }
48
+ >
49
+ <View style={styles.sheetContent}>
50
+ <Text style={styles.cardTitle}>Uptown & The Bronx</Text>
51
+ <Text style={styles.cardBody}>Next train in 4 min · every 6–8 min.</Text>
52
+ {/* Long enough to overflow the phone frame's fixed height — demonstrates the Dock footer's
53
+ `elevated` shadow turning on automatically once this content area actually scrolls. */}
54
+ <Text style={styles.cardBody}>Board at the front car for a faster transfer at Union Sq.</Text>
55
+ <Text style={styles.cardBody}>
56
+ This line runs express between 96 St and 168 St during rush hours, skipping local stops in
57
+ between. Weekend service runs local along the full route, with some stations closed for
58
+ planned maintenance.
59
+ </Text>
60
+ <Text style={styles.cardBody}>
61
+ Elevators are available at 168 St, 137 St, and 125 St — check the map for accessible
62
+ entrances before you travel.
63
+ </Text>
64
+ </View>
65
+ </BottomSheet>
66
+ </PhoneFrame>
67
+ );
68
+ }
69
+
70
+ export default defineCatalogPage({
71
+ component: 'BottomSheet',
72
+ group: 'Navigation',
73
+ description: 'A sheet that slides up over a dismissible backdrop. Height is driven by its content (not fixed snap points) up to 90% of the available height, then the content area scrolls. Compose header with TopNav and footer with Dock.',
74
+ whenToUse: 'For longer, browsable content, or anything that benefits from a TopNav/Dock header-footer structure. For a short, focused decision that interrupts the flow, use Dialog.',
75
+ a11y: 'The backdrop is a Pressable with accessibilityRole="button" and accessibilityLabel="Dismiss"; header/content/footer carry their own accessibility (e.g. TopNav\'s title as accessibilityRole="header").',
76
+ render: () => <BottomSheetDemo />,
77
+ });
@@ -0,0 +1,151 @@
1
+ import React, { createContext, type ReactNode, isValidElement, cloneElement, useContext, useEffect, useRef, useState } from 'react';
2
+ import { View, Pressable, ScrollView, Animated, Easing, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_SEMANTIC, DS_RADIUS, DS_SHADOW, DS_SPACING, DS_MOTION_DURATION, DS_MOTION_EASING } from '../../tokens';
4
+ import { Dock, type DockProps } from '../Dock';
5
+
6
+ const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
7
+ const MAX_HEIGHT_PERCENT = '90%';
8
+
9
+ /** True while rendering inside an already-open BottomSheet's tree (header/children/footer alike).
10
+ * Any component that opens its OWN BottomSheet (e.g. Dropdown) reads this via
11
+ * `useInsideBottomSheetWarning` to dev-warn against stacking two sheets — a sheet already covers
12
+ * the whole screen, so a second one has nowhere sensible to appear and just traps the user between
13
+ * two backdrops. Defaults to `false` for anything rendered outside a BottomSheet entirely. */
14
+ const BottomSheetContext = createContext(false);
15
+
16
+ /** Call this from a component that opens its own BottomSheet (Dropdown, another picker, …) — warns
17
+ * once per render if it's currently nested inside a different, already-open BottomSheet. Console
18
+ * warning only (never throws), matching this codebase's other dev-time consistency checks (e.g.
19
+ * ButtonGroup's `checkConsistency`). */
20
+ export function useInsideBottomSheetWarning(componentName: string): void {
21
+ const insideBottomSheet = useContext(BottomSheetContext);
22
+ if (insideBottomSheet) {
23
+ console.warn(
24
+ `[${componentName}] is opening a BottomSheet from inside another, already-open BottomSheet. Never stack two sheets — restructure so ${componentName} lives outside the outer sheet (e.g. in the screen that opens it), or replace it with an inline control (e.g. SegmentedToggle/Radio) for use inside a sheet.`,
25
+ );
26
+ }
27
+ }
28
+
29
+ export interface BottomSheetProps {
30
+ /** Whether the sheet (and its backdrop) are mounted and shown. */
31
+ visible: boolean;
32
+ /** Called when the backdrop is tapped — the sheet doesn't close itself; the caller decides. */
33
+ onDismiss: () => void;
34
+ /** Top area — typically a TopNav with a close/back action. */
35
+ header?: ReactNode;
36
+ /** Middle content area. Grows with its content up to 90% of the available height, then scrolls. */
37
+ children: ReactNode;
38
+ /** Bottom area — typically a Dock with the sheet's primary actions. When `footer` is a Dock
39
+ * element, its `elevated` prop is driven automatically from whether the content area above it
40
+ * actually overflows (needs to scroll) — no need to pass `elevated` yourself. */
41
+ footer?: ReactNode;
42
+ style?: StyleProp<ViewStyle>;
43
+ }
44
+
45
+ /**
46
+ * A sheet that slides up from the bottom over a dismissible backdrop. Height is driven by its
47
+ * content (not a fixed set of snap points) up to a cap, past which the content area scrolls. Not
48
+ * portaled through RN's `Modal` — it's a plain absolutely-positioned overlay, so it fills whichever
49
+ * parent View hosts it (RN Views are positioning contexts by default), which also lets the catalog
50
+ * contain a live demo inside a bounded preview frame instead of covering the whole page.
51
+ */
52
+ export function BottomSheet({ visible, onDismiss, header, children, footer, style }: BottomSheetProps) {
53
+ const progress = useRef(new Animated.Value(0)).current;
54
+ const [mounted, setMounted] = useState(visible);
55
+ // The sheet's own rendered height, measured via onLayout — its closed position is "translated
56
+ // down by exactly its own height" (fully below the visible area), not a small fixed nudge, so the
57
+ // motion genuinely reads as sliding in from off-screen regardless of how tall the content is.
58
+ // Falls back to a generous guess before the first measurement lands, to avoid a jump on first open.
59
+ const [sheetHeight, setSheetHeight] = useState(0);
60
+ // Drives a Dock footer's `elevated` shadow: on only once the content area's actual measured
61
+ // height exceeds what it's laid out to show, i.e. it's genuinely scrollable — not just "there is
62
+ // a Dock", so a short sheet stays flat and only a scrolling one gets the separating shadow.
63
+ const [scrollLayoutHeight, setScrollLayoutHeight] = useState(0);
64
+ const [scrollContentHeight, setScrollContentHeight] = useState(0);
65
+ const contentOverflows = scrollContentHeight > scrollLayoutHeight + 1;
66
+
67
+ useEffect(() => {
68
+ if (visible) setMounted(true);
69
+ // Backdrop opacity still fades (a scrim has no position to slide from); the sheet itself only
70
+ // ever moves via translateY below — no opacity animation on the sheet, just the slide.
71
+ const anim = Animated.timing(progress, {
72
+ toValue: visible ? 1 : 0,
73
+ duration: visible ? DS_MOTION_DURATION.base : DS_MOTION_DURATION.fast,
74
+ // `decelerate` — a sheet entering the screen, per DS_MOTION_EASING_USE (previously left on
75
+ // RN's default in-out ease, the one enter/exit overlay without an explicit curve).
76
+ easing: Easing.bezier(...DS_MOTION_EASING.decelerate),
77
+ useNativeDriver: false,
78
+ });
79
+ anim.start(({ finished }) => {
80
+ if (finished && !visible) setMounted(false);
81
+ });
82
+ return () => anim.stop();
83
+ }, [visible, progress]);
84
+
85
+ if (!mounted) return null;
86
+
87
+ const translateY = progress.interpolate({ inputRange: [0, 1], outputRange: [sheetHeight || 400, 0] });
88
+
89
+ const resolvedFooter =
90
+ isValidElement(footer) && footer.type === Dock
91
+ ? cloneElement(footer as React.ReactElement<DockProps>, { elevated: contentOverflows })
92
+ : footer;
93
+
94
+ return (
95
+ <View style={styles.overlay} pointerEvents={visible ? 'auto' : 'none'}>
96
+ <AnimatedPressable
97
+ style={[styles.backdrop, { opacity: progress }]}
98
+ onPress={onDismiss}
99
+ accessibilityRole="button"
100
+ accessibilityLabel="Dismiss"
101
+ />
102
+ <Animated.View
103
+ onLayout={(e) => setSheetHeight(e.nativeEvent.layout.height)}
104
+ style={[styles.sheet, style, { transform: [{ translateY }] }]}
105
+ // Contain assistive-tech focus while open (iOS) — without this, content behind the scrim
106
+ // stays reachable by screen readers even though it's visually blocked.
107
+ accessibilityViewIsModal
108
+ >
109
+ <View style={styles.handle} />
110
+ <BottomSheetContext.Provider value>
111
+ {header}
112
+ <ScrollView
113
+ style={styles.content}
114
+ contentContainerStyle={styles.contentContainer}
115
+ bounces={false}
116
+ onLayout={(e) => setScrollLayoutHeight(e.nativeEvent.layout.height)}
117
+ onContentSizeChange={(_width, height) => setScrollContentHeight(height)}
118
+ >
119
+ {children}
120
+ </ScrollView>
121
+ {resolvedFooter}
122
+ </BottomSheetContext.Provider>
123
+ </Animated.View>
124
+ </View>
125
+ );
126
+ }
127
+
128
+ const styles = StyleSheet.create({
129
+ overlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'flex-end' },
130
+ backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: DS_SEMANTIC.element.overlayBackdrop },
131
+ sheet: {
132
+ maxHeight: MAX_HEIGHT_PERCENT,
133
+ backgroundColor: DS_SEMANTIC.surface.white,
134
+ borderTopLeftRadius: DS_RADIUS.large,
135
+ borderTopRightRadius: DS_RADIUS.large,
136
+ ...DS_SHADOW.bottomSheet,
137
+ },
138
+ // Purely a visual affordance (this sheet has no drag-to-dismiss gesture wired up) — signals "you
139
+ // can swipe this" the same way native sheets do, sized to the common ~36×4 grabber convention.
140
+ handle: {
141
+ alignSelf: 'center',
142
+ width: 36,
143
+ height: 4,
144
+ borderRadius: DS_RADIUS.round,
145
+ backgroundColor: DS_SEMANTIC.border.light,
146
+ marginTop: DS_SPACING[300],
147
+ marginBottom: DS_SPACING[100],
148
+ },
149
+ content: { flexGrow: 0 },
150
+ contentContainer: { padding: DS_SPACING[800] },
151
+ });
@@ -0,0 +1,2 @@
1
+ export { BottomSheet, useInsideBottomSheetWarning } from './BottomSheet';
2
+ export type { BottomSheetProps } from './BottomSheet';