@himakarinv/nativex 1.0.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/images.d.ts +14 -0
  4. package/index.js +8 -0
  5. package/nativewind-preset.js +18 -0
  6. package/package.json +120 -0
  7. package/src/assets/branding/logo.jpg +0 -0
  8. package/src/assets/branding/softech-logo.png +0 -0
  9. package/src/assets/icons/2-layers.svg +3 -0
  10. package/src/assets/icons/Forbidden.svg +211 -0
  11. package/src/assets/icons/Internal-server-error.svg +119 -0
  12. package/src/assets/icons/Log-out-nav.svg +4 -0
  13. package/src/assets/icons/Not-Found.svg +282 -0
  14. package/src/assets/icons/Unauthorized.svg +314 -0
  15. package/src/assets/icons/User-Frame.svg +5 -0
  16. package/src/assets/icons/alert.svg +4 -0
  17. package/src/assets/icons/arrow-right.svg +3 -0
  18. package/src/assets/icons/avatar-female.svg +10 -0
  19. package/src/assets/icons/avatar-male.svg +10 -0
  20. package/src/assets/icons/bell.svg +3 -0
  21. package/src/assets/icons/calendar-event.svg +11 -0
  22. package/src/assets/icons/calendar.svg +6 -0
  23. package/src/assets/icons/card.svg +4 -0
  24. package/src/assets/icons/caret-right.svg +3 -0
  25. package/src/assets/icons/chat.svg +11 -0
  26. package/src/assets/icons/check.svg +3 -0
  27. package/src/assets/icons/checkbox-minus.svg +3 -0
  28. package/src/assets/icons/checkmark.svg +3 -0
  29. package/src/assets/icons/chevron-down.svg +3 -0
  30. package/src/assets/icons/chevron-left.svg +9 -0
  31. package/src/assets/icons/chevron-right.svg +9 -0
  32. package/src/assets/icons/circle-radio-active.svg +10 -0
  33. package/src/assets/icons/circle-radio.svg +10 -0
  34. package/src/assets/icons/clock.svg +3 -0
  35. package/src/assets/icons/close.svg +3 -0
  36. package/src/assets/icons/code.svg +3 -0
  37. package/src/assets/icons/country.svg +20 -0
  38. package/src/assets/icons/dot.svg +3 -0
  39. package/src/assets/icons/download.svg +1 -0
  40. package/src/assets/icons/dropdown-arrow.svg +3 -0
  41. package/src/assets/icons/dropdown-avatar.svg +3 -0
  42. package/src/assets/icons/email.svg +3 -0
  43. package/src/assets/icons/error.svg +3 -0
  44. package/src/assets/icons/eye-off.svg +1 -0
  45. package/src/assets/icons/eye.svg +1 -0
  46. package/src/assets/icons/hashtag-icon.svg +3 -0
  47. package/src/assets/icons/help-circle.svg +10 -0
  48. package/src/assets/icons/help.svg +4 -0
  49. package/src/assets/icons/hide-checkbox.svg +3 -0
  50. package/src/assets/icons/home-smile.svg +3 -0
  51. package/src/assets/icons/home.svg +3 -0
  52. package/src/assets/icons/info.svg +5 -0
  53. package/src/assets/icons/left-arrow.svg +3 -0
  54. package/src/assets/icons/log-out.svg +3 -0
  55. package/src/assets/icons/notification-box.svg +4 -0
  56. package/src/assets/icons/pencil.svg +1 -0
  57. package/src/assets/icons/plus.svg +3 -0
  58. package/src/assets/icons/radio-dot.svg +3 -0
  59. package/src/assets/icons/right-arrow.svg +3 -0
  60. package/src/assets/icons/search.svg +3 -0
  61. package/src/assets/icons/settings.svg +11 -0
  62. package/src/assets/icons/slack.svg +17 -0
  63. package/src/assets/icons/slash.svg +3 -0
  64. package/src/assets/icons/success.svg +3 -0
  65. package/src/assets/icons/successicon.svg +5 -0
  66. package/src/assets/icons/three-dot-vertical.svg +5 -0
  67. package/src/assets/icons/trash.svg +1 -0
  68. package/src/assets/icons/trend-down-01.svg +3 -0
  69. package/src/assets/icons/trend-up-01.svg +3 -0
  70. package/src/assets/icons/up-arrow.svg +3 -0
  71. package/src/assets/icons/user-plus.svg +10 -0
  72. package/src/assets/icons/user.svg +3 -0
  73. package/src/assets/icons/users.svg +3 -0
  74. package/src/assets/icons/vertical-dots-true-icon.svg +5 -0
  75. package/src/assets/icons/vertical-line.svg +3 -0
  76. package/src/assets/icons/vertical-three-dots-icon.svg +5 -0
  77. package/src/assets/icons/warning.svg +3 -0
  78. package/src/assets/icons/zap.svg +3 -0
  79. package/src/components/ActionsMenu/ActionsMenu.tsx +314 -0
  80. package/src/components/ActionsMenu/index.ts +2 -0
  81. package/src/components/Alert/Alert.tsx +291 -0
  82. package/src/components/Alert/index.ts +2 -0
  83. package/src/components/Badge/Badge.tsx +230 -0
  84. package/src/components/Badge/index.ts +2 -0
  85. package/src/components/Breadcrumb/Breadcrumb.tsx +364 -0
  86. package/src/components/Breadcrumb/index.ts +7 -0
  87. package/src/components/Button/Button.tsx +584 -0
  88. package/src/components/Button/index.ts +9 -0
  89. package/src/components/Checkbox/Checkbox.tsx +336 -0
  90. package/src/components/Checkbox/index.ts +2 -0
  91. package/src/components/CreatePassword/CreatePassword.tsx +161 -0
  92. package/src/components/CreatePassword/index.ts +2 -0
  93. package/src/components/DataTable/DataTable.tsx +822 -0
  94. package/src/components/DataTable/index.ts +10 -0
  95. package/src/components/Datepicker/Datepicker.tsx +797 -0
  96. package/src/components/Datepicker/index.ts +2 -0
  97. package/src/components/Dropdown/Dropdown.tsx +277 -0
  98. package/src/components/Dropdown/index.ts +7 -0
  99. package/src/components/DropdownMenu/DropdownMenu.tsx +471 -0
  100. package/src/components/DropdownMenu/index.ts +11 -0
  101. package/src/components/DynamicForm/DynamicForm.tsx +741 -0
  102. package/src/components/DynamicForm/index.ts +13 -0
  103. package/src/components/ErrorPages/ErrorPages.tsx +135 -0
  104. package/src/components/ErrorPages/index.ts +2 -0
  105. package/src/components/ForgotPassword/ForgotPassword.tsx +121 -0
  106. package/src/components/ForgotPassword/index.ts +2 -0
  107. package/src/components/HintError/HintError.tsx +37 -0
  108. package/src/components/HintError/index.ts +2 -0
  109. package/src/components/Icon/Icon.tsx +43 -0
  110. package/src/components/Icon/icon-registry.ts +227 -0
  111. package/src/components/Icon/index.ts +3 -0
  112. package/src/components/InputDropdown/InputDropdown.tsx +662 -0
  113. package/src/components/InputDropdown/index.ts +9 -0
  114. package/src/components/InputField/InputField.tsx +527 -0
  115. package/src/components/InputField/index.ts +2 -0
  116. package/src/components/Label/Label.tsx +88 -0
  117. package/src/components/Label/index.ts +2 -0
  118. package/src/components/LoginForm/LoginForm.tsx +158 -0
  119. package/src/components/LoginForm/index.ts +2 -0
  120. package/src/components/LoginShell/LoginShell.tsx +97 -0
  121. package/src/components/LoginShell/RoleCard.tsx +24 -0
  122. package/src/components/LoginShell/SoftechLogo.tsx +24 -0
  123. package/src/components/LoginShell/branding-assets.ts +2 -0
  124. package/src/components/LoginShell/index.ts +20 -0
  125. package/src/components/LoginShell/login-copy.ts +45 -0
  126. package/src/components/LoginShell/loginPageStyles.ts +210 -0
  127. package/src/components/Modals/Modals.tsx +313 -0
  128. package/src/components/Modals/index.ts +2 -0
  129. package/src/components/Offcanvas/Offcanvas.tsx +404 -0
  130. package/src/components/Offcanvas/index.ts +13 -0
  131. package/src/components/PageHeader/PageHeader.tsx +128 -0
  132. package/src/components/PageHeader/index.ts +2 -0
  133. package/src/components/Pagination/Pagination.tsx +540 -0
  134. package/src/components/Pagination/index.ts +8 -0
  135. package/src/components/ResetPassword/ResetPassword.tsx +172 -0
  136. package/src/components/ResetPassword/index.ts +2 -0
  137. package/src/components/RoleSelection/RoleSelection.tsx +111 -0
  138. package/src/components/RoleSelection/index.ts +2 -0
  139. package/src/components/SendOtp/SendOtp.tsx +149 -0
  140. package/src/components/SendOtp/index.ts +2 -0
  141. package/src/components/Sidenavbar/Sidenavbar.tsx +358 -0
  142. package/src/components/Sidenavbar/index.ts +2 -0
  143. package/src/components/StatCard/StatCard.tsx +393 -0
  144. package/src/components/StatCard/index.ts +6 -0
  145. package/src/components/Tabs/Tabs.tsx +239 -0
  146. package/src/components/Tabs/index.ts +2 -0
  147. package/src/components/Textarea/Textarea.tsx +217 -0
  148. package/src/components/Textarea/index.ts +2 -0
  149. package/src/components/Timepicker/Timepicker.tsx +384 -0
  150. package/src/components/Timepicker/index.ts +2 -0
  151. package/src/components/Toast/Toast.tsx +278 -0
  152. package/src/components/Toast/index.ts +2 -0
  153. package/src/components/Toggle/Toggle.tsx +296 -0
  154. package/src/components/Toggle/index.ts +2 -0
  155. package/src/components/Typeahead/Typeahead.tsx +521 -0
  156. package/src/components/Typeahead/index.ts +2 -0
  157. package/src/components/Verification/Verification.tsx +374 -0
  158. package/src/components/Verification/index.ts +2 -0
  159. package/src/index.ts +39 -0
  160. package/src/theme/borders.ts +19 -0
  161. package/src/theme/button.ts +41 -0
  162. package/src/theme/colors.ts +273 -0
  163. package/src/theme/index.ts +14 -0
  164. package/src/theme/nativewind-theme.js +182 -0
  165. package/src/theme/radius.ts +12 -0
  166. package/src/theme/shadows.ts +40 -0
  167. package/src/theme/spacing.ts +18 -0
  168. package/src/theme/typography.ts +35 -0
  169. package/src/utils/a11y.ts +21 -0
  170. package/svg.d.ts +7 -0
@@ -0,0 +1,313 @@
1
+ import { useEffect, useState, type ReactNode } from 'react';
2
+ import {
3
+ Modal,
4
+ ScrollView,
5
+ StyleSheet,
6
+ Text,
7
+ useWindowDimensions,
8
+ View,
9
+ type StyleProp,
10
+ type ViewStyle,
11
+ } from 'react-native';
12
+
13
+ import { button as buttonTokens, colors, radius, spacing, typography } from '../../theme';
14
+ import { Button, ButtonSize, ButtonVariant } from '../Button';
15
+ import { Checkbox, CheckboxSize } from '../Checkbox';
16
+
17
+ export const ModalsSize = {
18
+ Small: 'sm',
19
+ Medium: 'md',
20
+ Large: 'lg',
21
+ } as const;
22
+
23
+ export type ModalsSize = (typeof ModalsSize)[keyof typeof ModalsSize];
24
+
25
+ export const ModalsHeaderMediaAlign = {
26
+ Start: 'start',
27
+ Center: 'center',
28
+ End: 'end',
29
+ } as const;
30
+
31
+ export type ModalsHeaderMediaAlign = (typeof ModalsHeaderMediaAlign)[keyof typeof ModalsHeaderMediaAlign];
32
+
33
+ export type ModalsResultWithDontShowAgain =
34
+ | { action: 'confirm'; dontShowAgain: boolean }
35
+ | { action: 'cancel'; dontShowAgain: boolean };
36
+
37
+ export type ModalsProps = {
38
+ visible?: boolean;
39
+ title?: string | null;
40
+ headerSubtitle?: ReactNode;
41
+ headerMedia?: ReactNode;
42
+ headerMediaAlign?: ModalsHeaderMediaAlign;
43
+ showFooter?: boolean;
44
+ cancelText?: string;
45
+ confirmText?: string;
46
+ showDontShowAgain?: boolean;
47
+ dontShowAgainLabel?: string;
48
+ dontShowAgainChecked?: boolean;
49
+ size?: ModalsSize;
50
+ children?: ReactNode;
51
+ onCancel?: (result?: ModalsResultWithDontShowAgain | 'cancel') => void;
52
+ onConfirm?: (result?: ModalsResultWithDontShowAgain | 'confirm') => void;
53
+ onDontShowAgainChange?: (checked: boolean) => void;
54
+ style?: StyleProp<ViewStyle>;
55
+ };
56
+
57
+ const HEIGHT_RATIO: Record<ModalsSize, number> = {
58
+ [ModalsSize.Small]: 0.48,
59
+ [ModalsSize.Medium]: 0.58,
60
+ [ModalsSize.Large]: 0.68,
61
+ };
62
+
63
+ const WIDTH_CAP: Record<ModalsSize, number> = {
64
+ [ModalsSize.Small]: spacing['7xl'] * 6,
65
+ [ModalsSize.Medium]: spacing['7xl'] * 7,
66
+ [ModalsSize.Large]: spacing['7xl'] * 8,
67
+ };
68
+
69
+ const FOOTER_BUTTON_SIZE: Record<ModalsSize, ButtonSize> = {
70
+ [ModalsSize.Small]: ButtonSize.Small,
71
+ [ModalsSize.Medium]: ButtonSize.Medium,
72
+ [ModalsSize.Large]: ButtonSize.Large,
73
+ };
74
+
75
+ const HEADER_ALIGN: Record<ModalsHeaderMediaAlign, { items: 'flex-start' | 'center' | 'flex-end'; text: 'left' | 'center' | 'right' }> =
76
+ {
77
+ [ModalsHeaderMediaAlign.Start]: { items: 'flex-start', text: 'left' },
78
+ [ModalsHeaderMediaAlign.Center]: { items: 'center', text: 'center' },
79
+ [ModalsHeaderMediaAlign.End]: { items: 'flex-end', text: 'right' },
80
+ };
81
+
82
+ export function Modals({
83
+ visible = false,
84
+ title = null,
85
+ headerSubtitle,
86
+ headerMedia,
87
+ headerMediaAlign = ModalsHeaderMediaAlign.Start,
88
+ showFooter = true,
89
+ cancelText = 'Cancel',
90
+ confirmText = 'Confirm',
91
+ showDontShowAgain = false,
92
+ dontShowAgainLabel = "Don't show again",
93
+ dontShowAgainChecked,
94
+ size = ModalsSize.Medium,
95
+ children,
96
+ onCancel,
97
+ onConfirm,
98
+ onDontShowAgainChange,
99
+ style,
100
+ }: ModalsProps) {
101
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
102
+ const [skipChecked, setSkipChecked] = useState(dontShowAgainChecked ?? false);
103
+ const [headerHeight, setHeaderHeight] = useState<number>(spacing.none);
104
+ const [footerHeight, setFooterHeight] = useState<number>(spacing.none);
105
+
106
+ useEffect(() => {
107
+ setSkipChecked(dontShowAgainChecked ?? false);
108
+ }, [dontShowAgainChecked]);
109
+
110
+ useEffect(() => {
111
+ if (!showDontShowAgain) {
112
+ setSkipChecked(false);
113
+ }
114
+ }, [showDontShowAgain]);
115
+
116
+ useEffect(() => {
117
+ if (!showFooter) {
118
+ setFooterHeight(spacing.none);
119
+ }
120
+ }, [showFooter]);
121
+
122
+ const align = HEADER_ALIGN[headerMediaAlign];
123
+ const availableWidth = Math.max(spacing.none, windowWidth - spacing['2xl'] * 2);
124
+ const cardWidth = Math.min(availableWidth, WIDTH_CAP[size]);
125
+ const cardMaxHeight = Math.round(windowHeight * HEIGHT_RATIO[size]);
126
+ const chromeHeight = headerHeight + footerHeight;
127
+ const bodyMaxHeight = Math.max(spacing['7xl'], cardMaxHeight - chromeHeight);
128
+ const footerButtonSize = FOOTER_BUTTON_SIZE[size];
129
+ const compactLine = typography.fontSize.sm * typography.lineHeight.tight;
130
+ const titleLine = typography.fontSize.lg * typography.lineHeight.tight;
131
+
132
+ function emit(action: 'confirm' | 'cancel') {
133
+ if (showDontShowAgain) {
134
+ const result = { action, dontShowAgain: skipChecked };
135
+ if (action === 'confirm') {
136
+ onConfirm?.(result);
137
+ } else {
138
+ onCancel?.(result);
139
+ }
140
+ return;
141
+ }
142
+ if (action === 'confirm') {
143
+ onConfirm?.('confirm');
144
+ } else {
145
+ onCancel?.('cancel');
146
+ }
147
+ }
148
+
149
+ function onSkipChange(checked: boolean) {
150
+ setSkipChecked(checked);
151
+ onDontShowAgainChange?.(checked);
152
+ }
153
+
154
+ return (
155
+ <Modal
156
+ visible={visible}
157
+ transparent
158
+ animationType="fade"
159
+ statusBarTranslucent
160
+ presentationStyle="overFullScreen"
161
+ onRequestClose={() => emit('cancel')}>
162
+ <View style={styles.overlay}>
163
+ <View
164
+ accessibilityViewIsModal
165
+ style={[styles.card, { width: cardWidth, maxHeight: cardMaxHeight }, style]}>
166
+ {title || headerMedia || headerSubtitle ? (
167
+ <View
168
+ style={[styles.header, { alignItems: align.items }]}
169
+ onLayout={(event) => setHeaderHeight(event.nativeEvent.layout.height)}>
170
+ {headerMedia ? <View style={[styles.headerMedia, { justifyContent: align.items }]}>{headerMedia}</View> : null}
171
+ {title ? (
172
+ <Text
173
+ nativeID="modals-title"
174
+ accessibilityRole="header"
175
+ style={[styles.title, { textAlign: align.text, lineHeight: titleLine }]}>
176
+ {title}
177
+ </Text>
178
+ ) : null}
179
+ {headerSubtitle ? (
180
+ <View style={styles.subtitleWrap}>
181
+ {typeof headerSubtitle === 'string' ? (
182
+ <Text style={[styles.subtitle, { textAlign: align.text, lineHeight: compactLine }]}>
183
+ {headerSubtitle}
184
+ </Text>
185
+ ) : (
186
+ headerSubtitle
187
+ )}
188
+ </View>
189
+ ) : null}
190
+ </View>
191
+ ) : null}
192
+
193
+ <ScrollView
194
+ style={[styles.body, { maxHeight: bodyMaxHeight }]}
195
+ contentContainerStyle={styles.bodyContent}
196
+ keyboardShouldPersistTaps="handled"
197
+ nestedScrollEnabled
198
+ showsVerticalScrollIndicator={false}>
199
+ {typeof children === 'string' ? (
200
+ <Text style={[styles.bodyText, { lineHeight: compactLine }]}>{children}</Text>
201
+ ) : (
202
+ children
203
+ )}
204
+ </ScrollView>
205
+
206
+ {showFooter ? (
207
+ <View style={styles.footer} onLayout={(event) => setFooterHeight(event.nativeEvent.layout.height)}>
208
+ {showDontShowAgain ? (
209
+ <Checkbox
210
+ size={CheckboxSize.Small}
211
+ checked={skipChecked}
212
+ label={dontShowAgainLabel}
213
+ onCheckedChange={onSkipChange}
214
+ style={styles.skip}
215
+ />
216
+ ) : null}
217
+ <View style={styles.actions}>
218
+ <Button
219
+ label={confirmText}
220
+ variant={ButtonVariant.Primary}
221
+ size={footerButtonSize}
222
+ fullWidth
223
+ onPress={() => emit('confirm')}
224
+ />
225
+ <Button
226
+ label={cancelText}
227
+ variant={ButtonVariant.SecondaryGray}
228
+ size={footerButtonSize}
229
+ fullWidth
230
+ onPress={() => emit('cancel')}
231
+ />
232
+ </View>
233
+ </View>
234
+ ) : null}
235
+ </View>
236
+ </View>
237
+ </Modal>
238
+ );
239
+ }
240
+
241
+ const styles = StyleSheet.create({
242
+ overlay: {
243
+ flex: 1,
244
+ alignItems: 'center',
245
+ justifyContent: 'center',
246
+ paddingHorizontal: spacing['2xl'],
247
+ paddingVertical: spacing['2xl'],
248
+ backgroundColor: colors.modal.backdrop,
249
+ },
250
+ card: {
251
+ backgroundColor: colors.white,
252
+ borderRadius: radius.lg,
253
+ overflow: 'hidden',
254
+ },
255
+ header: {
256
+ width: '100%',
257
+ gap: spacing.md,
258
+ paddingHorizontal: spacing.base,
259
+ paddingVertical: spacing.md,
260
+ },
261
+ headerMedia: {
262
+ width: '100%',
263
+ flexDirection: 'row',
264
+ },
265
+ title: {
266
+ width: '100%',
267
+ fontFamily: typography.fontFamily.base,
268
+ fontSize: typography.fontSize.lg,
269
+ fontWeight: typography.fontWeight.semibold,
270
+ color: colors.inputDropdown.text,
271
+ },
272
+ subtitleWrap: {
273
+ width: '100%',
274
+ marginTop: -spacing.sm,
275
+ },
276
+ subtitle: {
277
+ width: '100%',
278
+ fontFamily: typography.fontFamily.base,
279
+ fontSize: typography.fontSize.sm,
280
+ fontWeight: typography.fontWeight.regular,
281
+ color: colors.gray.dark,
282
+ },
283
+ body: {
284
+ flexGrow: 0,
285
+ flexShrink: 1,
286
+ width: '100%',
287
+ },
288
+ bodyContent: {
289
+ paddingHorizontal: spacing.base,
290
+ paddingVertical: spacing.md,
291
+ },
292
+ bodyText: {
293
+ fontFamily: typography.fontFamily.base,
294
+ fontSize: typography.fontSize.sm,
295
+ fontWeight: typography.fontWeight.regular,
296
+ color: colors.gray.dark,
297
+ },
298
+ footer: {
299
+ width: '100%',
300
+ gap: spacing.md,
301
+ paddingHorizontal: spacing.base,
302
+ paddingVertical: spacing.md,
303
+ },
304
+ skip: {
305
+ alignSelf: 'flex-start',
306
+ },
307
+ actions: {
308
+ width: '100%',
309
+ gap: spacing.md,
310
+ },
311
+ });
312
+
313
+ export const HEADER_MEDIA_SIZE = buttonTokens.height.lg;
@@ -0,0 +1,2 @@
1
+ export { Modals, ModalsSize, ModalsHeaderMediaAlign, HEADER_MEDIA_SIZE } from './Modals';
2
+ export type { ModalsProps, ModalsResultWithDontShowAgain } from './Modals';
@@ -0,0 +1,404 @@
1
+ import { type ReactNode } from 'react';
2
+ import {
3
+ Keyboard,
4
+ KeyboardAvoidingView,
5
+ Modal,
6
+ Platform,
7
+ Pressable,
8
+ ScrollView,
9
+ StyleSheet,
10
+ Text,
11
+ useWindowDimensions,
12
+ View,
13
+ type StyleProp,
14
+ type ViewStyle,
15
+ } from 'react-native';
16
+ import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
17
+
18
+ import { borders, button as buttonTokens, colors, radius, spacing, typography } from '../../theme';
19
+ import { Button, ButtonSize, ButtonVariant, IconPosition } from '../Button';
20
+ import { IconName } from '../Icon';
21
+
22
+ export const OffcanvasSize = {
23
+ Small: 'sm',
24
+ Medium: 'md',
25
+ Large: 'lg',
26
+ ExtraLarge: 'xl',
27
+ ExtraExtraLarge: 'xxl',
28
+ } as const;
29
+
30
+ export type OffcanvasSize = (typeof OffcanvasSize)[keyof typeof OffcanvasSize];
31
+
32
+ export const DEFAULT_OFFCANVAS_SIZE: OffcanvasSize = OffcanvasSize.Medium;
33
+
34
+ export const OffcanvasPosition = {
35
+ Start: 'start',
36
+ End: 'end',
37
+ Top: 'top',
38
+ Bottom: 'bottom',
39
+ } as const;
40
+
41
+ export type OffcanvasPosition = (typeof OffcanvasPosition)[keyof typeof OffcanvasPosition];
42
+
43
+ export type OffcanvasBackdrop = boolean | 'static';
44
+
45
+ export type OffcanvasFooterButton = {
46
+ action: string;
47
+ label: string;
48
+ variant: ButtonVariant;
49
+ size?: ButtonSize;
50
+ ariaLabel?: string;
51
+ disabled?: boolean;
52
+ };
53
+
54
+ export type OffcanvasOpenConfig = {
55
+ size?: OffcanvasSize;
56
+ position?: OffcanvasPosition;
57
+ backdrop?: OffcanvasBackdrop;
58
+ keyboard?: boolean;
59
+ scroll?: boolean;
60
+ animation?: boolean;
61
+ };
62
+
63
+ export type OffcanvasProps = {
64
+ visible?: boolean;
65
+ title: string;
66
+ titleId?: string;
67
+ showCloseButton?: boolean;
68
+ closeAriaLabel?: string;
69
+ footerButtons?: ReadonlyArray<OffcanvasFooterButton>;
70
+ size?: OffcanvasSize;
71
+ position?: OffcanvasPosition;
72
+ backdrop?: OffcanvasBackdrop;
73
+ keyboard?: boolean;
74
+ scroll?: boolean;
75
+ animation?: boolean;
76
+ children?: ReactNode;
77
+ onCloseRequested?: () => void;
78
+ onFooterAction?: (action: string) => void;
79
+ style?: StyleProp<ViewStyle>;
80
+ };
81
+
82
+ const SIDE_PEEK: Record<OffcanvasSize, number> = {
83
+ [OffcanvasSize.Small]: spacing['7xl'],
84
+ [OffcanvasSize.Medium]: spacing.xl,
85
+ [OffcanvasSize.Large]: spacing.md,
86
+ [OffcanvasSize.ExtraLarge]: spacing.none,
87
+ [OffcanvasSize.ExtraExtraLarge]: spacing.none,
88
+ };
89
+
90
+ const SHEET_HEIGHT_RATIO: Record<OffcanvasSize, number> = {
91
+ [OffcanvasSize.Small]: 0.42,
92
+ [OffcanvasSize.Medium]: 0.55,
93
+ [OffcanvasSize.Large]: 0.68,
94
+ [OffcanvasSize.ExtraLarge]: 0.82,
95
+ [OffcanvasSize.ExtraExtraLarge]: 0.94,
96
+ };
97
+
98
+ export function offcanvasOpenOptions(config: OffcanvasOpenConfig = {}) {
99
+ return {
100
+ size: config.size ?? DEFAULT_OFFCANVAS_SIZE,
101
+ position: config.position ?? OffcanvasPosition.End,
102
+ backdrop: config.backdrop ?? true,
103
+ keyboard: config.keyboard ?? true,
104
+ scroll: config.scroll ?? false,
105
+ animation: config.animation ?? true,
106
+ };
107
+ }
108
+
109
+ function isHorizontal(position: OffcanvasPosition) {
110
+ return position === OffcanvasPosition.Start || position === OffcanvasPosition.End;
111
+ }
112
+
113
+ function dismissKeyboard() {
114
+ Keyboard.dismiss();
115
+ }
116
+
117
+ export function Offcanvas({
118
+ visible = false,
119
+ title,
120
+ titleId = 'app-offcanvas-title',
121
+ showCloseButton = true,
122
+ closeAriaLabel = 'Close',
123
+ footerButtons = [],
124
+ size = DEFAULT_OFFCANVAS_SIZE,
125
+ position = OffcanvasPosition.End,
126
+ backdrop = true,
127
+ keyboard = true,
128
+ scroll: _scroll = false,
129
+ animation = true,
130
+ children,
131
+ onCloseRequested,
132
+ onFooterAction,
133
+ style,
134
+ }: OffcanvasProps) {
135
+ const animationType = !animation ? 'none' : position === OffcanvasPosition.Bottom ? 'slide' : 'fade';
136
+
137
+ function requestClose() {
138
+ dismissKeyboard();
139
+ onCloseRequested?.();
140
+ }
141
+
142
+ return (
143
+ <Modal
144
+ visible={visible}
145
+ transparent
146
+ animationType={animationType}
147
+ statusBarTranslucent
148
+ presentationStyle="overFullScreen"
149
+ onRequestClose={keyboard ? requestClose : undefined}>
150
+ <SafeAreaProvider>
151
+ <OffcanvasSheet
152
+ title={title}
153
+ titleId={titleId}
154
+ showCloseButton={showCloseButton}
155
+ closeAriaLabel={closeAriaLabel}
156
+ footerButtons={footerButtons}
157
+ size={size}
158
+ position={position}
159
+ backdrop={backdrop}
160
+ onCloseRequested={requestClose}
161
+ onFooterAction={onFooterAction}
162
+ style={style}>
163
+ {children}
164
+ </OffcanvasSheet>
165
+ </SafeAreaProvider>
166
+ </Modal>
167
+ );
168
+ }
169
+
170
+ type SheetProps = Omit<
171
+ OffcanvasProps,
172
+ 'visible' | 'keyboard' | 'animation' | 'scroll' | 'title'
173
+ > & {
174
+ title: string;
175
+ };
176
+
177
+ function OffcanvasSheet({
178
+ title,
179
+ titleId,
180
+ showCloseButton,
181
+ closeAriaLabel,
182
+ footerButtons = [],
183
+ size = DEFAULT_OFFCANVAS_SIZE,
184
+ position = OffcanvasPosition.End,
185
+ backdrop = true,
186
+ children,
187
+ onCloseRequested,
188
+ onFooterAction,
189
+ style,
190
+ }: SheetProps) {
191
+ const insets = useSafeAreaInsets();
192
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
193
+ const dimmed = backdrop !== false;
194
+ const dismissOnScrim = backdrop === true;
195
+ const horizontal = isHorizontal(position);
196
+ const panelWidth = Math.max(spacing['7xl'] * 5, windowWidth - SIDE_PEEK[size]);
197
+ const panelHeight = Math.round(windowHeight * SHEET_HEIGHT_RATIO[size]);
198
+ const titleLine = typography.fontSize.lg * 1.3;
199
+ const showHandle = position === OffcanvasPosition.Bottom || position === OffcanvasPosition.Top;
200
+
201
+ const overlayDirection = horizontal ? 'row' : 'column';
202
+ const panelFirst = position === OffcanvasPosition.Start || position === OffcanvasPosition.Top;
203
+
204
+ const panelRadius: ViewStyle =
205
+ position === OffcanvasPosition.End && SIDE_PEEK[size] > spacing.none
206
+ ? { borderTopLeftRadius: radius.lg, borderBottomLeftRadius: radius.lg }
207
+ : position === OffcanvasPosition.Start && SIDE_PEEK[size] > spacing.none
208
+ ? { borderTopRightRadius: radius.lg, borderBottomRightRadius: radius.lg }
209
+ : position === OffcanvasPosition.Bottom
210
+ ? { borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg }
211
+ : position === OffcanvasPosition.Top
212
+ ? { borderBottomLeftRadius: radius.lg, borderBottomRightRadius: radius.lg }
213
+ : {};
214
+
215
+ const panelSafe: ViewStyle = {
216
+ paddingTop: position === OffcanvasPosition.Bottom ? spacing.none : insets.top,
217
+ paddingBottom: position === OffcanvasPosition.Top ? spacing.none : insets.bottom,
218
+ paddingLeft: position === OffcanvasPosition.End ? spacing.none : insets.left,
219
+ paddingRight: position === OffcanvasPosition.Start ? spacing.none : insets.right,
220
+ };
221
+
222
+ const panelSize: ViewStyle = horizontal
223
+ ? { width: panelWidth, height: '100%' }
224
+ : { width: '100%', height: panelHeight };
225
+
226
+ function onFooterPress(button: OffcanvasFooterButton) {
227
+ if (button.disabled) {
228
+ return;
229
+ }
230
+ onFooterAction?.(button.action);
231
+ }
232
+
233
+ const handle = showHandle ? (
234
+ <View
235
+ style={position === OffcanvasPosition.Top ? styles.handleBottom : styles.handleTop}
236
+ accessible={false}>
237
+ <View style={styles.handle} />
238
+ </View>
239
+ ) : null;
240
+
241
+ const panel = (
242
+ <View
243
+ accessibilityViewIsModal
244
+ accessibilityLabel={title}
245
+ style={[styles.panel, panelSize, panelRadius, panelSafe, style]}>
246
+ <KeyboardAvoidingView
247
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
248
+ style={styles.panelInner}>
249
+ {position === OffcanvasPosition.Bottom ? handle : null}
250
+
251
+ <View style={styles.header}>
252
+ <Text
253
+ nativeID={titleId}
254
+ accessibilityRole="header"
255
+ numberOfLines={2}
256
+ style={[styles.title, { lineHeight: titleLine }]}>
257
+ {title}
258
+ </Text>
259
+ {showCloseButton ? (
260
+ <Button
261
+ variant={ButtonVariant.LinkGray}
262
+ size={ButtonSize.Small}
263
+ iconPosition={IconPosition.Only}
264
+ iconName={IconName.Close}
265
+ accessibilityLabel={closeAriaLabel}
266
+ onPress={onCloseRequested}
267
+ />
268
+ ) : null}
269
+ </View>
270
+
271
+ <ScrollView
272
+ style={styles.body}
273
+ contentContainerStyle={styles.bodyContent}
274
+ keyboardShouldPersistTaps="handled"
275
+ nestedScrollEnabled
276
+ showsVerticalScrollIndicator={false}>
277
+ {typeof children === 'string' ? <Text style={styles.bodyText}>{children}</Text> : children}
278
+ </ScrollView>
279
+
280
+ {footerButtons.length > 0 ? (
281
+ <View style={styles.footer}>
282
+ {footerButtons.map((button) => (
283
+ <View key={button.action} style={styles.footerItem}>
284
+ <Button
285
+ label={button.label}
286
+ variant={button.variant}
287
+ size={button.size ?? ButtonSize.Medium}
288
+ disabled={button.disabled}
289
+ accessibilityLabel={button.ariaLabel ?? button.label}
290
+ onPress={() => onFooterPress(button)}
291
+ />
292
+ </View>
293
+ ))}
294
+ </View>
295
+ ) : null}
296
+
297
+ {position === OffcanvasPosition.Top ? handle : null}
298
+ </KeyboardAvoidingView>
299
+ </View>
300
+ );
301
+
302
+ const scrim = (
303
+ <Pressable
304
+ style={styles.scrim}
305
+ disabled={!dismissOnScrim}
306
+ onPress={dismissOnScrim ? onCloseRequested : undefined}
307
+ accessibilityRole={dismissOnScrim ? 'button' : undefined}
308
+ accessibilityLabel={dismissOnScrim ? 'Dismiss' : undefined}
309
+ />
310
+ );
311
+
312
+ return (
313
+ <View
314
+ style={[
315
+ styles.overlay,
316
+ {
317
+ flexDirection: overlayDirection,
318
+ backgroundColor: dimmed ? colors.modal.backdrop : colors.transparent,
319
+ },
320
+ ]}>
321
+ {panelFirst ? panel : scrim}
322
+ {panelFirst ? scrim : panel}
323
+ </View>
324
+ );
325
+ }
326
+
327
+ const styles = StyleSheet.create({
328
+ overlay: {
329
+ flex: 1,
330
+ },
331
+ scrim: {
332
+ flex: 1,
333
+ },
334
+ panel: {
335
+ backgroundColor: colors.white,
336
+ overflow: 'hidden',
337
+ },
338
+ panelInner: {
339
+ flex: 1,
340
+ },
341
+ handleTop: {
342
+ alignItems: 'center',
343
+ paddingTop: spacing.sm,
344
+ },
345
+ handleBottom: {
346
+ alignItems: 'center',
347
+ paddingBottom: spacing.sm,
348
+ },
349
+ handle: {
350
+ width: spacing['7xl'],
351
+ height: spacing.sm,
352
+ borderRadius: radius.pill,
353
+ backgroundColor: colors.gray.medium,
354
+ },
355
+ header: {
356
+ flexDirection: 'row',
357
+ alignItems: 'center',
358
+ justifyContent: 'space-between',
359
+ gap: spacing.md,
360
+ paddingVertical: spacing.md,
361
+ paddingHorizontal: spacing.lg,
362
+ borderBottomWidth: borders.width.thin,
363
+ borderBottomColor: colors.borderSubtle,
364
+ },
365
+ title: {
366
+ flex: 1,
367
+ fontFamily: typography.fontFamily.base,
368
+ fontSize: typography.fontSize.lg,
369
+ fontWeight: typography.fontWeight.bold,
370
+ color: colors.text.primary,
371
+ },
372
+ body: {
373
+ flexGrow: 1,
374
+ flexShrink: 1,
375
+ minHeight: spacing.none,
376
+ },
377
+ bodyContent: {
378
+ paddingVertical: spacing.lg,
379
+ paddingHorizontal: spacing.lg,
380
+ gap: spacing.lg,
381
+ },
382
+ bodyText: {
383
+ fontFamily: typography.fontFamily.base,
384
+ fontSize: typography.fontSize.sm,
385
+ fontWeight: typography.fontWeight.regular,
386
+ color: colors.gray.dark,
387
+ lineHeight: typography.fontSize.sm * typography.lineHeight.tight,
388
+ },
389
+ footer: {
390
+ flexDirection: 'row',
391
+ flexWrap: 'wrap',
392
+ alignItems: 'center',
393
+ justifyContent: 'flex-end',
394
+ gap: spacing.md,
395
+ paddingVertical: spacing.md,
396
+ paddingHorizontal: spacing.lg,
397
+ borderTopWidth: borders.width.thin,
398
+ borderTopColor: colors.borderSubtle,
399
+ },
400
+ footerItem: {
401
+ justifyContent: 'center',
402
+ minHeight: buttonTokens.height.md,
403
+ },
404
+ });