@bacon-pos/ui 0.9.0 → 0.11.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 (175) hide show
  1. package/lib/commonjs/components/Alert/Alert.js +137 -0
  2. package/lib/commonjs/components/Alert/Alert.js.map +1 -0
  3. package/lib/commonjs/components/Alert/Alert.stories.js +104 -0
  4. package/lib/commonjs/components/Alert/Alert.stories.js.map +1 -0
  5. package/lib/commonjs/components/Alert/Alert.test.js +62 -0
  6. package/lib/commonjs/components/Alert/Alert.test.js.map +1 -0
  7. package/lib/commonjs/components/Alert/index.js +13 -0
  8. package/lib/commonjs/components/Alert/index.js.map +1 -0
  9. package/lib/commonjs/components/EmptyState/EmptyState.js +88 -0
  10. package/lib/commonjs/components/EmptyState/EmptyState.js.map +1 -0
  11. package/lib/commonjs/components/EmptyState/EmptyState.stories.js +46 -0
  12. package/lib/commonjs/components/EmptyState/EmptyState.stories.js.map +1 -0
  13. package/lib/commonjs/components/EmptyState/EmptyState.test.js +40 -0
  14. package/lib/commonjs/components/EmptyState/EmptyState.test.js.map +1 -0
  15. package/lib/commonjs/components/EmptyState/index.js +13 -0
  16. package/lib/commonjs/components/EmptyState/index.js.map +1 -0
  17. package/lib/commonjs/components/Sidebar/Sidebar.js +264 -0
  18. package/lib/commonjs/components/Sidebar/Sidebar.js.map +1 -0
  19. package/lib/commonjs/components/Sidebar/Sidebar.stories.js +229 -0
  20. package/lib/commonjs/components/Sidebar/Sidebar.stories.js.map +1 -0
  21. package/lib/commonjs/components/Sidebar/Sidebar.test.js +131 -0
  22. package/lib/commonjs/components/Sidebar/Sidebar.test.js.map +1 -0
  23. package/lib/commonjs/components/Sidebar/index.js +13 -0
  24. package/lib/commonjs/components/Sidebar/index.js.map +1 -0
  25. package/lib/commonjs/components/StatTile/StatTile.js +94 -0
  26. package/lib/commonjs/components/StatTile/StatTile.js.map +1 -0
  27. package/lib/commonjs/components/StatTile/StatTile.stories.js +100 -0
  28. package/lib/commonjs/components/StatTile/StatTile.stories.js.map +1 -0
  29. package/lib/commonjs/components/StatTile/StatTile.test.js +49 -0
  30. package/lib/commonjs/components/StatTile/StatTile.test.js.map +1 -0
  31. package/lib/commonjs/components/StatTile/index.js +13 -0
  32. package/lib/commonjs/components/StatTile/index.js.map +1 -0
  33. package/lib/commonjs/components/Table/DataTable.js +149 -0
  34. package/lib/commonjs/components/Table/DataTable.js.map +1 -0
  35. package/lib/commonjs/components/Table/DataTable.stories.js +214 -0
  36. package/lib/commonjs/components/Table/DataTable.stories.js.map +1 -0
  37. package/lib/commonjs/components/Table/DataTable.test.js +143 -0
  38. package/lib/commonjs/components/Table/DataTable.test.js.map +1 -0
  39. package/lib/commonjs/components/Table/Table.js +520 -0
  40. package/lib/commonjs/components/Table/Table.js.map +1 -0
  41. package/lib/commonjs/components/Table/Table.stories.js +385 -0
  42. package/lib/commonjs/components/Table/Table.stories.js.map +1 -0
  43. package/lib/commonjs/components/Table/Table.test.js +278 -0
  44. package/lib/commonjs/components/Table/Table.test.js.map +1 -0
  45. package/lib/commonjs/components/Table/index.js +20 -0
  46. package/lib/commonjs/components/Table/index.js.map +1 -0
  47. package/lib/commonjs/components/brand/BaconLogo.js +29 -0
  48. package/lib/commonjs/components/brand/BaconLogo.js.map +1 -0
  49. package/lib/commonjs/components/brand/index.js +7 -0
  50. package/lib/commonjs/components/brand/index.js.map +1 -1
  51. package/lib/commonjs/components/index.js +83 -28
  52. package/lib/commonjs/components/index.js.map +1 -1
  53. package/lib/module/components/Alert/Alert.js +133 -0
  54. package/lib/module/components/Alert/Alert.js.map +1 -0
  55. package/lib/module/components/Alert/Alert.stories.js +100 -0
  56. package/lib/module/components/Alert/Alert.stories.js.map +1 -0
  57. package/lib/module/components/Alert/Alert.test.js +62 -0
  58. package/lib/module/components/Alert/Alert.test.js.map +1 -0
  59. package/lib/module/components/Alert/index.js +4 -0
  60. package/lib/module/components/Alert/index.js.map +1 -0
  61. package/lib/module/components/EmptyState/EmptyState.js +84 -0
  62. package/lib/module/components/EmptyState/EmptyState.js.map +1 -0
  63. package/lib/module/components/EmptyState/EmptyState.stories.js +42 -0
  64. package/lib/module/components/EmptyState/EmptyState.stories.js.map +1 -0
  65. package/lib/module/components/EmptyState/EmptyState.test.js +40 -0
  66. package/lib/module/components/EmptyState/EmptyState.test.js.map +1 -0
  67. package/lib/module/components/EmptyState/index.js +4 -0
  68. package/lib/module/components/EmptyState/index.js.map +1 -0
  69. package/lib/module/components/Sidebar/Sidebar.js +260 -0
  70. package/lib/module/components/Sidebar/Sidebar.js.map +1 -0
  71. package/lib/module/components/Sidebar/Sidebar.stories.js +225 -0
  72. package/lib/module/components/Sidebar/Sidebar.stories.js.map +1 -0
  73. package/lib/module/components/Sidebar/Sidebar.test.js +131 -0
  74. package/lib/module/components/Sidebar/Sidebar.test.js.map +1 -0
  75. package/lib/module/components/Sidebar/index.js +4 -0
  76. package/lib/module/components/Sidebar/index.js.map +1 -0
  77. package/lib/module/components/StatTile/StatTile.js +90 -0
  78. package/lib/module/components/StatTile/StatTile.js.map +1 -0
  79. package/lib/module/components/StatTile/StatTile.stories.js +96 -0
  80. package/lib/module/components/StatTile/StatTile.stories.js.map +1 -0
  81. package/lib/module/components/StatTile/StatTile.test.js +49 -0
  82. package/lib/module/components/StatTile/StatTile.test.js.map +1 -0
  83. package/lib/module/components/StatTile/index.js +4 -0
  84. package/lib/module/components/StatTile/index.js.map +1 -0
  85. package/lib/module/components/Table/DataTable.js +145 -0
  86. package/lib/module/components/Table/DataTable.js.map +1 -0
  87. package/lib/module/components/Table/DataTable.stories.js +210 -0
  88. package/lib/module/components/Table/DataTable.stories.js.map +1 -0
  89. package/lib/module/components/Table/DataTable.test.js +143 -0
  90. package/lib/module/components/Table/DataTable.test.js.map +1 -0
  91. package/lib/module/components/Table/Table.js +516 -0
  92. package/lib/module/components/Table/Table.js.map +1 -0
  93. package/lib/module/components/Table/Table.stories.js +381 -0
  94. package/lib/module/components/Table/Table.stories.js.map +1 -0
  95. package/lib/module/components/Table/Table.test.js +278 -0
  96. package/lib/module/components/Table/Table.test.js.map +1 -0
  97. package/lib/module/components/Table/index.js +5 -0
  98. package/lib/module/components/Table/index.js.map +1 -0
  99. package/lib/module/components/brand/BaconLogo.js +25 -0
  100. package/lib/module/components/brand/BaconLogo.js.map +1 -0
  101. package/lib/module/components/brand/index.js +1 -0
  102. package/lib/module/components/brand/index.js.map +1 -1
  103. package/lib/module/components/index.js +5 -0
  104. package/lib/module/components/index.js.map +1 -1
  105. package/lib/typescript/commonjs/components/Alert/Alert.d.ts +41 -0
  106. package/lib/typescript/commonjs/components/Alert/Alert.d.ts.map +1 -0
  107. package/lib/typescript/commonjs/components/Alert/index.d.ts +3 -0
  108. package/lib/typescript/commonjs/components/Alert/index.d.ts.map +1 -0
  109. package/lib/typescript/commonjs/components/EmptyState/EmptyState.d.ts +30 -0
  110. package/lib/typescript/commonjs/components/EmptyState/EmptyState.d.ts.map +1 -0
  111. package/lib/typescript/commonjs/components/EmptyState/index.d.ts +3 -0
  112. package/lib/typescript/commonjs/components/EmptyState/index.d.ts.map +1 -0
  113. package/lib/typescript/commonjs/components/Sidebar/Sidebar.d.ts +57 -0
  114. package/lib/typescript/commonjs/components/Sidebar/Sidebar.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/components/Sidebar/index.d.ts +3 -0
  116. package/lib/typescript/commonjs/components/Sidebar/index.d.ts.map +1 -0
  117. package/lib/typescript/commonjs/components/StatTile/StatTile.d.ts +31 -0
  118. package/lib/typescript/commonjs/components/StatTile/StatTile.d.ts.map +1 -0
  119. package/lib/typescript/commonjs/components/StatTile/index.d.ts +3 -0
  120. package/lib/typescript/commonjs/components/StatTile/index.d.ts.map +1 -0
  121. package/lib/typescript/commonjs/components/Table/DataTable.d.ts +53 -0
  122. package/lib/typescript/commonjs/components/Table/DataTable.d.ts.map +1 -0
  123. package/lib/typescript/commonjs/components/Table/Table.d.ts +85 -0
  124. package/lib/typescript/commonjs/components/Table/Table.d.ts.map +1 -0
  125. package/lib/typescript/commonjs/components/Table/index.d.ts +5 -0
  126. package/lib/typescript/commonjs/components/Table/index.d.ts.map +1 -0
  127. package/lib/typescript/commonjs/components/brand/BaconLogo.d.ts +13 -0
  128. package/lib/typescript/commonjs/components/brand/BaconLogo.d.ts.map +1 -0
  129. package/lib/typescript/commonjs/components/brand/index.d.ts +1 -0
  130. package/lib/typescript/commonjs/components/brand/index.d.ts.map +1 -1
  131. package/lib/typescript/commonjs/components/index.d.ts +5 -0
  132. package/lib/typescript/commonjs/components/index.d.ts.map +1 -1
  133. package/lib/typescript/module/components/Alert/Alert.d.ts +41 -0
  134. package/lib/typescript/module/components/Alert/Alert.d.ts.map +1 -0
  135. package/lib/typescript/module/components/Alert/index.d.ts +3 -0
  136. package/lib/typescript/module/components/Alert/index.d.ts.map +1 -0
  137. package/lib/typescript/module/components/EmptyState/EmptyState.d.ts +30 -0
  138. package/lib/typescript/module/components/EmptyState/EmptyState.d.ts.map +1 -0
  139. package/lib/typescript/module/components/EmptyState/index.d.ts +3 -0
  140. package/lib/typescript/module/components/EmptyState/index.d.ts.map +1 -0
  141. package/lib/typescript/module/components/Sidebar/Sidebar.d.ts +57 -0
  142. package/lib/typescript/module/components/Sidebar/Sidebar.d.ts.map +1 -0
  143. package/lib/typescript/module/components/Sidebar/index.d.ts +3 -0
  144. package/lib/typescript/module/components/Sidebar/index.d.ts.map +1 -0
  145. package/lib/typescript/module/components/StatTile/StatTile.d.ts +31 -0
  146. package/lib/typescript/module/components/StatTile/StatTile.d.ts.map +1 -0
  147. package/lib/typescript/module/components/StatTile/index.d.ts +3 -0
  148. package/lib/typescript/module/components/StatTile/index.d.ts.map +1 -0
  149. package/lib/typescript/module/components/Table/DataTable.d.ts +53 -0
  150. package/lib/typescript/module/components/Table/DataTable.d.ts.map +1 -0
  151. package/lib/typescript/module/components/Table/Table.d.ts +85 -0
  152. package/lib/typescript/module/components/Table/Table.d.ts.map +1 -0
  153. package/lib/typescript/module/components/Table/index.d.ts +5 -0
  154. package/lib/typescript/module/components/Table/index.d.ts.map +1 -0
  155. package/lib/typescript/module/components/brand/BaconLogo.d.ts +13 -0
  156. package/lib/typescript/module/components/brand/BaconLogo.d.ts.map +1 -0
  157. package/lib/typescript/module/components/brand/index.d.ts +1 -0
  158. package/lib/typescript/module/components/brand/index.d.ts.map +1 -1
  159. package/lib/typescript/module/components/index.d.ts +5 -0
  160. package/lib/typescript/module/components/index.d.ts.map +1 -1
  161. package/package.json +1 -1
  162. package/src/components/Alert/Alert.tsx +132 -0
  163. package/src/components/Alert/index.ts +2 -0
  164. package/src/components/EmptyState/EmptyState.tsx +91 -0
  165. package/src/components/EmptyState/index.ts +2 -0
  166. package/src/components/Sidebar/Sidebar.tsx +320 -0
  167. package/src/components/Sidebar/index.ts +2 -0
  168. package/src/components/StatTile/StatTile.tsx +98 -0
  169. package/src/components/StatTile/index.ts +2 -0
  170. package/src/components/Table/DataTable.tsx +173 -0
  171. package/src/components/Table/Table.tsx +556 -0
  172. package/src/components/Table/index.ts +10 -0
  173. package/src/components/brand/BaconLogo.tsx +25 -0
  174. package/src/components/brand/index.ts +1 -0
  175. package/src/components/index.ts +5 -0
@@ -0,0 +1,320 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
2
+ import {
3
+ Animated,
4
+ Easing,
5
+ Pressable,
6
+ View,
7
+ type StyleProp,
8
+ type ViewStyle,
9
+ } from 'react-native';
10
+ import { useTheme } from '../../theme';
11
+ import { BaconLogo } from '../brand/BaconLogo';
12
+ import { Icon } from '../Icon/Icon';
13
+ import type { IconName } from '../Icon/icons.generated';
14
+ import { IconButton } from '../IconButton/IconButton';
15
+ import { Text } from '../Text/Text';
16
+ import { ScrollArea } from '../_internal/ScrollArea';
17
+
18
+ export interface SidebarItem {
19
+ /** Stable key; also the active identifier. */
20
+ key: string;
21
+ /** Button text (hidden when collapsed). */
22
+ label: string;
23
+ /** Leading icon. When omitted, expanded reserves the icon space and collapsed shows the label's initial. */
24
+ icon?: IconName;
25
+ /** Optional icon shown when the item is active (e.g. a filled variant). */
26
+ activeIcon?: IconName;
27
+ /** Press handler. */
28
+ onPress?: () => void;
29
+ /** Dim and block the item. */
30
+ disabled?: boolean;
31
+ }
32
+
33
+ export interface SidebarProps {
34
+ /** Navigation items — rendered top-to-bottom. Defaults to none. */
35
+ items?: SidebarItem[];
36
+ /** Active item key (controlled). */
37
+ activeKey?: string;
38
+ /** Active item key for the uncontrolled case. */
39
+ defaultActiveKey?: string;
40
+ /** Called with the pressed item's key. */
41
+ onChange?: (key: string) => void;
42
+
43
+ /** Collapsed (icons only) state — controlled. */
44
+ collapsed?: boolean;
45
+ /** Initial collapsed state (uncontrolled). Defaults to false. */
46
+ defaultCollapsed?: boolean;
47
+ /** Called when the collapse state changes. */
48
+ onCollapsedChange?: (collapsed: boolean) => void;
49
+ /** Show the built-in expand/collapse toggle. Defaults to true. */
50
+ showToggle?: boolean;
51
+
52
+ /** Logo shown at the top. Defaults to the Bacon mark. */
53
+ logo?: ReactNode;
54
+ /** Bottom supporting text (e.g. a version). Defaults to none. */
55
+ footer?: string;
56
+
57
+ /** Expanded width. Defaults to 284 (Figma). */
58
+ expandedWidth?: number;
59
+ /** Collapsed width. Defaults to 122 (Figma). */
60
+ collapsedWidth?: number;
61
+ /** Scroll indicator color. Defaults to a subtle neutral. */
62
+ scrollbarColor?: string;
63
+ /** Override the container style. */
64
+ style?: StyleProp<ViewStyle>;
65
+ }
66
+
67
+ const ITEM_HEIGHT = 74;
68
+ const ITEM_GAP = 16;
69
+ const PADDING = 24;
70
+ const TOGGLE_SIZE = 40; // IconButton size "sm"
71
+
72
+ function SidebarButton({
73
+ item,
74
+ active,
75
+ collapsed,
76
+ onPress,
77
+ }: {
78
+ item: SidebarItem;
79
+ active: boolean;
80
+ collapsed: boolean;
81
+ onPress: () => void;
82
+ }) {
83
+ const theme = useTheme();
84
+ const [hovered, setHovered] = useState(false);
85
+ const disabled = item.disabled;
86
+
87
+ const backgroundColor = active
88
+ ? theme.colors.action.secondary
89
+ : hovered && !disabled
90
+ ? theme.colors.background.muted
91
+ : 'transparent';
92
+ const contentColor = active
93
+ ? theme.colors.action.secondaryForeground
94
+ : theme.colors.text.secondary;
95
+ const iconColor = active ? theme.colors.action.secondaryForeground : 'secondary';
96
+ const iconName = active && item.activeIcon ? item.activeIcon : item.icon;
97
+ const initial = item.label.trim().charAt(0).toUpperCase();
98
+
99
+ // Leading slot is always a fixed 24×24 box (flexShrink: 0) so the icon never
100
+ // shrinks next to a long label and labels stay aligned with or without an icon.
101
+ // No icon → reserve the space when expanded; show the initial when collapsed.
102
+ const leadingBox: ViewStyle = {
103
+ width: 24,
104
+ height: 24,
105
+ flexShrink: 0,
106
+ alignItems: 'center',
107
+ justifyContent: 'center',
108
+ };
109
+ const leading = (
110
+ <View style={leadingBox}>
111
+ {iconName ? (
112
+ <Icon name={iconName} size={24} color={iconColor} />
113
+ ) : collapsed ? (
114
+ <Text variant="bodyLarge" weight="medium" color={contentColor}>
115
+ {initial}
116
+ </Text>
117
+ ) : null}
118
+ </View>
119
+ );
120
+
121
+ return (
122
+ <Pressable
123
+ onPress={disabled ? undefined : onPress}
124
+ onHoverIn={() => setHovered(true)}
125
+ onHoverOut={() => setHovered(false)}
126
+ disabled={disabled}
127
+ accessibilityRole="tab"
128
+ accessibilityState={{ selected: active, disabled }}
129
+ accessibilityLabel={item.label}
130
+ style={{
131
+ flexDirection: 'row',
132
+ alignItems: 'center',
133
+ justifyContent: collapsed ? 'center' : 'flex-start',
134
+ gap: 10,
135
+ height: ITEM_HEIGHT,
136
+ paddingHorizontal: PADDING,
137
+ borderRadius: theme.radii.pill,
138
+ borderWidth: active ? 1 : 0,
139
+ borderColor: active ? theme.colors.border.default : 'transparent',
140
+ backgroundColor,
141
+ opacity: disabled ? 0.5 : 1,
142
+ }}
143
+ >
144
+ {leading}
145
+ {!collapsed ? (
146
+ <Text
147
+ variant="bodyLarge"
148
+ weight="medium"
149
+ color={contentColor}
150
+ numberOfLines={1}
151
+ ellipsizeMode="tail"
152
+ style={{ flexShrink: 1, minWidth: 0 }}
153
+ >
154
+ {item.label}
155
+ </Text>
156
+ ) : null}
157
+ </Pressable>
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Sidebar — the app's main navigation drawer (Figma "Sidebar"). Expands/collapses
163
+ * between a labelled and an icon-only rail (animated width). The logo defaults to
164
+ * the Bacon mark (override via `logo`); an optional `footer` shows supporting text
165
+ * at the bottom. Items are dynamic buttons (icon + label via props); the list
166
+ * scrolls with an elegant auto-hiding indicator when it overflows, leaving the
167
+ * footer pinned. Buttons have hover and active states to mark the current section.
168
+ */
169
+ export function Sidebar({
170
+ items = [],
171
+ activeKey,
172
+ defaultActiveKey,
173
+ onChange,
174
+ collapsed: collapsedProp,
175
+ defaultCollapsed = false,
176
+ onCollapsedChange,
177
+ showToggle = true,
178
+ logo,
179
+ footer,
180
+ expandedWidth = 284,
181
+ collapsedWidth = 122,
182
+ scrollbarColor,
183
+ style,
184
+ }: SidebarProps) {
185
+ const theme = useTheme();
186
+
187
+ const activeControlled = activeKey !== undefined;
188
+ const [activeInternal, setActiveInternal] = useState(defaultActiveKey);
189
+ const active = activeControlled ? activeKey : activeInternal;
190
+
191
+ const collapseControlled = collapsedProp !== undefined;
192
+ const [collapsedInternal, setCollapsedInternal] = useState(defaultCollapsed);
193
+ const collapsed = collapseControlled ? collapsedProp : collapsedInternal;
194
+
195
+ const width = useRef(new Animated.Value(collapsed ? collapsedWidth : expandedWidth)).current;
196
+ // `visualCollapsed` drives the layout (labels, alignment, footer). It lags on
197
+ // collapse — labels stay mounted (clipped by overflow) until the width finishes
198
+ // shrinking — and leads on expand, so both directions animate without a jump.
199
+ const [visualCollapsed, setVisualCollapsed] = useState(collapsed);
200
+ useEffect(() => {
201
+ if (!collapsed) setVisualCollapsed(false);
202
+ const anim = Animated.timing(width, {
203
+ toValue: collapsed ? collapsedWidth : expandedWidth,
204
+ duration: 220,
205
+ easing: Easing.out(Easing.ease),
206
+ useNativeDriver: false, // width
207
+ });
208
+ anim.start(({ finished }) => {
209
+ if (finished && collapsed) setVisualCollapsed(true);
210
+ });
211
+ return () => anim.stop();
212
+ }, [collapsed, collapsedWidth, expandedWidth, width]);
213
+
214
+ // The toggle arrow spins in place (0° → 180°) instead of moving, so collapsing
215
+ // never nudges it. Driven by intent (`collapsed`), in sync with the width.
216
+ const arrowRot = useRef(new Animated.Value(collapsed ? 1 : 0)).current;
217
+ useEffect(() => {
218
+ const anim = Animated.timing(arrowRot, {
219
+ toValue: collapsed ? 1 : 0,
220
+ duration: 220,
221
+ easing: Easing.out(Easing.ease),
222
+ useNativeDriver: true,
223
+ });
224
+ anim.start();
225
+ return () => anim.stop();
226
+ }, [collapsed, arrowRot]);
227
+ const arrowRotate = arrowRot.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] });
228
+
229
+ // Glide the toggle from flush-left (expanded) to centered on the icon axis
230
+ // (collapsed), tracking the width so it never jumps.
231
+ const toggleTranslateX = width.interpolate({
232
+ inputRange: [collapsedWidth, expandedWidth],
233
+ outputRange: [Math.max(0, (collapsedWidth - 2 * PADDING - TOGGLE_SIZE) / 2), 0],
234
+ extrapolate: 'clamp',
235
+ });
236
+
237
+ const selectItem = (item: SidebarItem) => {
238
+ if (!activeControlled) setActiveInternal(item.key);
239
+ item.onPress?.();
240
+ onChange?.(item.key);
241
+ };
242
+
243
+ const toggleCollapsed = () => {
244
+ const next = !collapsed;
245
+ if (!collapseControlled) setCollapsedInternal(next);
246
+ onCollapsedChange?.(next);
247
+ };
248
+
249
+ const logoNode = logo ?? <BaconLogo height={visualCollapsed ? 36 : 40} />;
250
+ const toggle = showToggle ? (
251
+ <Animated.View style={{ transform: [{ translateX: toggleTranslateX }] }}>
252
+ <Animated.View style={{ transform: [{ rotate: arrowRotate }] }}>
253
+ <IconButton
254
+ icon="arrow-left"
255
+ accessibilityLabel={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
256
+ variant="ghost"
257
+ size="sm"
258
+ onPress={toggleCollapsed}
259
+ />
260
+ </Animated.View>
261
+ </Animated.View>
262
+ ) : null;
263
+
264
+ return (
265
+ <Animated.View
266
+ style={[
267
+ {
268
+ width,
269
+ height: '100%',
270
+ backgroundColor: theme.colors.background.default,
271
+ paddingTop: 48,
272
+ paddingBottom: PADDING,
273
+ paddingHorizontal: PADDING,
274
+ overflow: 'hidden',
275
+ },
276
+ style,
277
+ ]}
278
+ >
279
+ {/* Logo */}
280
+ <View
281
+ style={{
282
+ minHeight: 56,
283
+ alignItems: visualCollapsed ? 'center' : 'flex-start',
284
+ justifyContent: 'center',
285
+ }}
286
+ >
287
+ {logoNode}
288
+ </View>
289
+
290
+ {/* Tabs (scrolls when overflowing; footer stays pinned below) */}
291
+ <ScrollArea
292
+ style={{ flex: 1, marginTop: 32 }}
293
+ indicatorColor={scrollbarColor ?? theme.colors.border.strong}
294
+ contentContainerStyle={{ gap: ITEM_GAP, paddingBottom: 8 }}
295
+ >
296
+ {items.map((item) => (
297
+ <SidebarButton
298
+ key={item.key}
299
+ item={item}
300
+ active={item.key === active}
301
+ collapsed={visualCollapsed}
302
+ onPress={() => selectItem(item)}
303
+ />
304
+ ))}
305
+ </ScrollArea>
306
+
307
+ {/* Bottom: supporting footer + fixed collapse/expand toggle */}
308
+ <View style={{ marginTop: 28, gap: 16 }}>
309
+ {footer && !visualCollapsed ? (
310
+ <Text variant="bodySmall" color="tertiary" numberOfLines={1} style={{ marginLeft: 8 }}>
311
+ {footer}
312
+ </Text>
313
+ ) : null}
314
+ {toggle ? (
315
+ <View style={{ alignItems: 'flex-start' }}>{toggle}</View>
316
+ ) : null}
317
+ </View>
318
+ </Animated.View>
319
+ );
320
+ }
@@ -0,0 +1,2 @@
1
+ export { Sidebar } from './Sidebar';
2
+ export type { SidebarProps, SidebarItem } from './Sidebar';
@@ -0,0 +1,98 @@
1
+ import { type ReactNode } from 'react';
2
+ import { Pressable, View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useTheme } from '../../theme';
4
+ import { Icon } from '../Icon/Icon';
5
+ import type { IconName } from '../Icon/icons.generated';
6
+ import { Text } from '../Text/Text';
7
+
8
+ export interface StatTileProps {
9
+ /** Small label above the value. */
10
+ label: string;
11
+ /** The value — a string/number, or a node for custom formatting/color. */
12
+ value: ReactNode;
13
+ /** Optional leading icon. */
14
+ icon?: IconName;
15
+ /** Fixed width. Omit to fill the parent (works in a flex/grid row of tiles). */
16
+ width?: number;
17
+ /** Make the tile pressable. */
18
+ onPress?: () => void;
19
+ /** Tile background. Defaults to `background.subtle`. */
20
+ backgroundColor?: string;
21
+ /** Label color. Defaults to `secondary`. */
22
+ labelColor?: string;
23
+ /** Value color (when `value` is text). Defaults to `primary`. */
24
+ valueColor?: string;
25
+ /** Override the container style. */
26
+ style?: StyleProp<ViewStyle>;
27
+ accessibilityLabel?: string;
28
+ }
29
+
30
+ /**
31
+ * StatTile — a compact KPI card: a small label over a prominent value (Figma
32
+ * "info card"). Fills its parent by default (so a row/grid of tiles sizes evenly)
33
+ * or takes a fixed `width`. `value` accepts a node for custom color/formatting.
34
+ */
35
+ export function StatTile({
36
+ label,
37
+ value,
38
+ icon,
39
+ width,
40
+ onPress,
41
+ backgroundColor,
42
+ labelColor,
43
+ valueColor,
44
+ style,
45
+ accessibilityLabel,
46
+ }: StatTileProps) {
47
+ const theme = useTheme();
48
+
49
+ const content = (
50
+ <>
51
+ {icon ? (
52
+ <View style={{ width: 24, height: 24, flexShrink: 0 }}>
53
+ <Icon name={icon} size={24} color="secondary" />
54
+ </View>
55
+ ) : null}
56
+ <View style={{ flex: 1, minWidth: 0, gap: 4 }}>
57
+ <Text variant="bodySmall" color={labelColor ?? 'secondary'} numberOfLines={1}>
58
+ {label}
59
+ </Text>
60
+ {typeof value === 'string' || typeof value === 'number' ? (
61
+ <Text variant="bodyLarge" weight="semibold" color={valueColor ?? 'primary'} numberOfLines={1}>
62
+ {String(value)}
63
+ </Text>
64
+ ) : (
65
+ value
66
+ )}
67
+ </View>
68
+ </>
69
+ );
70
+
71
+ const containerStyle: ViewStyle = {
72
+ flexDirection: 'row',
73
+ alignItems: 'center',
74
+ gap: 12,
75
+ padding: 12,
76
+ borderRadius: theme.radii.lg,
77
+ backgroundColor: backgroundColor ?? theme.colors.background.subtle,
78
+ ...(width != null ? { width } : { alignSelf: 'stretch' }),
79
+ };
80
+
81
+ if (!onPress) {
82
+ return (
83
+ <View accessibilityLabel={accessibilityLabel} style={[containerStyle, style]}>
84
+ {content}
85
+ </View>
86
+ );
87
+ }
88
+ return (
89
+ <Pressable
90
+ onPress={onPress}
91
+ accessibilityRole="button"
92
+ accessibilityLabel={accessibilityLabel ?? label}
93
+ style={({ pressed }) => [containerStyle, { opacity: pressed ? 0.96 : 1 }, style]}
94
+ >
95
+ {content}
96
+ </Pressable>
97
+ );
98
+ }
@@ -0,0 +1,2 @@
1
+ export { StatTile } from './StatTile';
2
+ export type { StatTileProps } from './StatTile';
@@ -0,0 +1,173 @@
1
+ import { useState } from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useTheme } from '../../theme';
4
+ import { Button } from '../Button/Button';
5
+ import { ChipGroup } from '../Chip/ChipGroup';
6
+ import { EmptyState, type EmptyStateProps } from '../EmptyState/EmptyState';
7
+ import { Icon } from '../Icon/Icon';
8
+ import type { IconName } from '../Icon/icons.generated';
9
+ import { IconButton } from '../IconButton/IconButton';
10
+ import { Input } from '../Input/Input';
11
+ import { Text } from '../Text/Text';
12
+ import { Table, type TableProps } from './Table';
13
+
14
+ export interface DataTableTab {
15
+ value: string;
16
+ label: string;
17
+ icon?: IconName;
18
+ }
19
+
20
+ export interface DataTablePagination {
21
+ /** 1-based current page. */
22
+ page: number;
23
+ pageSize: number;
24
+ /** Total row count (across pages). */
25
+ total: number;
26
+ onPageChange: (page: number) => void;
27
+ /** Customize the range label. Defaults to `${from}–${to} of ${total}`. */
28
+ formatRange?: (from: number, to: number, total: number) => string;
29
+ }
30
+
31
+ export interface DataTableProps<T> extends Omit<TableProps<T>, 'empty'> {
32
+ /** Tabs above the toolbar (reuses `ChipGroup`). */
33
+ tabs?: DataTableTab[];
34
+ /** Active tab (controlled). */
35
+ activeTab?: string;
36
+ defaultTab?: string;
37
+ onTabChange?: (value: string) => void;
38
+
39
+ /** Show the search field. */
40
+ searchable?: boolean;
41
+ searchValue?: string;
42
+ onSearchChange?: (value: string) => void;
43
+ searchPlaceholder?: string;
44
+
45
+ /** Show a create button on the right of the toolbar. */
46
+ createLabel?: string;
47
+ onCreate?: () => void;
48
+ createIcon?: IconName;
49
+
50
+ /** Structured empty state (alternative to Table's `empty` node). */
51
+ emptyState?: EmptyStateProps;
52
+ /** Raw empty node (wins over `emptyState`). */
53
+ empty?: React.ReactNode;
54
+
55
+ /** Pagination controls below the table. */
56
+ pagination?: DataTablePagination;
57
+
58
+ /** Gap between toolbar sections. Defaults to 16. */
59
+ gap?: number;
60
+ containerStyle?: StyleProp<ViewStyle>;
61
+ }
62
+
63
+ /**
64
+ * DataTable — the turnkey management table (Figma): optional tabs (`ChipGroup`),
65
+ * a search + create toolbar, the `Table` body, a structured `EmptyState`, and
66
+ * optional pagination — assembled with consistent spacing. For bespoke layouts,
67
+ * compose `Table` + `EmptyState` yourself.
68
+ */
69
+ export function DataTable<T>({
70
+ tabs,
71
+ activeTab,
72
+ defaultTab,
73
+ onTabChange,
74
+ searchable = false,
75
+ searchValue,
76
+ onSearchChange,
77
+ searchPlaceholder = 'Search…',
78
+ createLabel,
79
+ onCreate,
80
+ createIcon = 'plus',
81
+ emptyState,
82
+ empty,
83
+ pagination,
84
+ gap = 16,
85
+ containerStyle,
86
+ ...tableProps
87
+ }: DataTableProps<T>) {
88
+ const theme = useTheme();
89
+
90
+ const tabControlled = activeTab !== undefined;
91
+ const [tabInternal, setTabInternal] = useState<string | null>(defaultTab ?? tabs?.[0]?.value ?? null);
92
+ const currentTab = tabControlled ? activeTab : tabInternal;
93
+ const setTab = (value: string | null) => {
94
+ if (value == null) return; // tabs are not deselectable
95
+ if (!tabControlled) setTabInternal(value);
96
+ onTabChange?.(value);
97
+ };
98
+
99
+ const emptyNode = empty ?? (emptyState ? <EmptyState {...emptyState} /> : undefined);
100
+
101
+ const showToolbar = searchable || !!createLabel;
102
+
103
+ return (
104
+ <View style={[{ gap }, containerStyle]}>
105
+ {tabs && tabs.length > 0 ? (
106
+ <ChipGroup
107
+ tone="dark"
108
+ options={tabs.map((t) => ({ value: t.value, label: t.label, icon: t.icon }))}
109
+ value={currentTab}
110
+ onChange={setTab}
111
+ />
112
+ ) : null}
113
+
114
+ {showToolbar ? (
115
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
116
+ {searchable ? (
117
+ <View style={{ flex: 1, minWidth: 220 }}>
118
+ <Input
119
+ value={searchValue}
120
+ onChangeText={onSearchChange}
121
+ placeholder={searchPlaceholder}
122
+ leftIcon={<Icon name="search" size={24} color="tertiary" />}
123
+ />
124
+ </View>
125
+ ) : null}
126
+ {createLabel ? (
127
+ <Button
128
+ label={createLabel}
129
+ variant="inverse"
130
+ size="md"
131
+ onPress={onCreate}
132
+ leftIcon={<Icon name={createIcon} size={24} color={theme.colors.action.inverseForeground} />}
133
+ />
134
+ ) : null}
135
+ </View>
136
+ ) : null}
137
+
138
+ <Table {...tableProps} empty={emptyNode} />
139
+
140
+ {pagination ? <Paginator {...pagination} /> : null}
141
+ </View>
142
+ );
143
+ }
144
+
145
+ function Paginator({ page, pageSize, total, onPageChange, formatRange }: DataTablePagination) {
146
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
147
+ const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
148
+ const to = Math.min(page * pageSize, total);
149
+
150
+ return (
151
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 12 }}>
152
+ <Text variant="bodySmall" color="secondary">
153
+ {formatRange ? formatRange(from, to, total) : `${from}–${to} of ${total}`}
154
+ </Text>
155
+ <IconButton
156
+ icon="arrow-left"
157
+ accessibilityLabel="Previous page"
158
+ variant="secondary"
159
+ size="sm"
160
+ disabled={page <= 1}
161
+ onPress={() => onPageChange(page - 1)}
162
+ />
163
+ <IconButton
164
+ icon="arrow-right"
165
+ accessibilityLabel="Next page"
166
+ variant="secondary"
167
+ size="sm"
168
+ disabled={page >= totalPages}
169
+ onPress={() => onPageChange(page + 1)}
170
+ />
171
+ </View>
172
+ );
173
+ }