@utilitywarehouse/hearth-react-native 0.30.4 → 0.31.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 (63) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-lint.log +12 -15
  3. package/CHANGELOG.md +149 -0
  4. package/build/components/Badge/Badge.js +2 -2
  5. package/build/components/Badge/Badge.props.d.ts +1 -0
  6. package/build/components/Badge/BadgeText.d.ts +1 -1
  7. package/build/components/Badge/BadgeText.js +2 -2
  8. package/build/components/Container/Container.props.d.ts +2 -2
  9. package/build/components/ExpandableCard/ExpandableCard.d.ts +1 -1
  10. package/build/components/ExpandableCard/ExpandableCard.js +13 -2
  11. package/build/components/ExpandableCard/ExpandableCard.props.d.ts +43 -23
  12. package/build/components/ExpandableCard/ExpandableCardText.js +1 -1
  13. package/build/components/ExpandableCard/ExpandableCardTrigger.d.ts +3 -3
  14. package/build/components/ExpandableCard/ExpandableCardTrigger.props.d.ts +31 -6
  15. package/build/components/ExpandableCard/ExpandableCardTriggerRoot.d.ts +1 -1
  16. package/build/components/ExpandableCard/ExpandableCardTriggerRoot.js +13 -2
  17. package/build/components/Flex/Flex.props.d.ts +2 -2
  18. package/build/components/FormField/FormField.d.ts +5 -5
  19. package/build/components/FormField/FormField.js +3 -2
  20. package/build/components/Modal/Modal.d.ts +1 -1
  21. package/build/components/Modal/Modal.js +33 -39
  22. package/build/components/Modal/Modal.props.d.ts +8 -3
  23. package/build/components/Modal/Modal.shared.types.d.ts +19 -4
  24. package/build/components/Modal/Modal.web.d.ts +1 -1
  25. package/build/components/Modal/Modal.web.js +6 -3
  26. package/build/components/NavModal/NavModal.d.ts +1 -1
  27. package/build/components/NavModal/NavModal.js +10 -7
  28. package/build/components/NavModal/NavModal.props.d.ts +4 -3
  29. package/build/components/Textarea/Textarea.d.ts +1 -1
  30. package/build/components/Textarea/Textarea.js +64 -5
  31. package/build/components/Textarea/Textarea.props.d.ts +10 -0
  32. package/build/components/Textarea/TextareaRoot.js +4 -1
  33. package/docs/changelog.mdx +21 -0
  34. package/package.json +1 -1
  35. package/src/components/Badge/Badge.props.ts +1 -0
  36. package/src/components/Badge/Badge.tsx +6 -1
  37. package/src/components/Badge/BadgeText.tsx +8 -2
  38. package/src/components/Container/Container.props.ts +10 -1
  39. package/src/components/ExpandableCard/ExpandableCard.docs.mdx +89 -37
  40. package/src/components/ExpandableCard/ExpandableCard.props.ts +51 -27
  41. package/src/components/ExpandableCard/ExpandableCard.stories.tsx +67 -17
  42. package/src/components/ExpandableCard/ExpandableCard.tsx +15 -7
  43. package/src/components/ExpandableCard/ExpandableCardText.tsx +1 -1
  44. package/src/components/ExpandableCard/ExpandableCardTrigger.props.ts +37 -7
  45. package/src/components/ExpandableCard/ExpandableCardTriggerRoot.tsx +36 -2
  46. package/src/components/Flex/Flex.props.ts +16 -2
  47. package/src/components/FormField/FormField.tsx +2 -1
  48. package/src/components/List/List.stories.tsx +35 -0
  49. package/src/components/Modal/Modal.docs.mdx +52 -1
  50. package/src/components/Modal/Modal.props.ts +21 -6
  51. package/src/components/Modal/Modal.shared.types.ts +23 -4
  52. package/src/components/Modal/Modal.stories.tsx +165 -1
  53. package/src/components/Modal/Modal.tsx +101 -81
  54. package/src/components/Modal/Modal.web.tsx +29 -23
  55. package/src/components/NavModal/NavModal.docs.mdx +29 -0
  56. package/src/components/NavModal/NavModal.props.ts +11 -3
  57. package/src/components/NavModal/NavModal.stories.tsx +29 -0
  58. package/src/components/NavModal/NavModal.tsx +39 -33
  59. package/src/components/Textarea/Textarea.docs.mdx +33 -1
  60. package/src/components/Textarea/Textarea.props.ts +11 -2
  61. package/src/components/Textarea/Textarea.stories.tsx +21 -1
  62. package/src/components/Textarea/Textarea.tsx +107 -3
  63. package/src/components/Textarea/TextareaRoot.tsx +6 -2
@@ -1,5 +1,5 @@
1
1
  import { Canvas, Controls, Meta, Story } from '@storybook/addon-docs/blocks';
2
- import { BodyText, BottomSheetModal, Box, Button, Center, Heading, Modal } from '../../';
2
+ import { BodyText, BottomSheetModal, Box, Button, Center, Flex, Heading, Modal } from '../../';
3
3
  import StorybookLink from '../../../../../shared/storybook/StorybookLink';
4
4
  import { BackToTopButton, UsageWrap, ViewFigmaButton } from '../../../docs/components';
5
5
  import * as Stories from './Modal.stories';
@@ -28,6 +28,7 @@ If you need a modal layout inside a React Navigation modal screen, use <Storyboo
28
28
  - [Modal with Image](#modal-with-image)
29
29
  - [Fullscreen Modal](#fullscreen-modal)
30
30
  - [Modal with Custom Content](#modal-with-custom-content)
31
+ - [Sticky Custom Footer](#sticky-custom-footer)
31
32
  - [Loading State](#loading-state)
32
33
  - [Without Close Button](#without-close-button)
33
34
  - [Single Action Modal](#single-action-modal)
@@ -107,9 +108,13 @@ The Modal component extends the `BottomSheetModal` component and accepts all of
107
108
  | `children` | `ReactNode` | Custom content to display in the modal body | - |
108
109
  | `primaryButtonProps` | `Omit<ButtonWithoutChildrenProps, 'children'>` | Additional props to pass to the primary button (colorScheme defaults to 'highlight', variant to 'solid') | - |
109
110
  | `secondaryButtonProps` | `Omit<ButtonWithoutChildrenProps, 'children'>` | Additional props to pass to the secondary button (colorScheme defaults to 'functional', variant to 'outline') | - |
111
+ | `footer` | `ReactNode` | Custom footer content that replaces the built-in action buttons | - |
112
+ | `footerStyle` | `StyleProp<ViewStyle>` | Styles applied to the footer container, useful for sticky footer shadows or custom spacing | - |
110
113
  | `closeButtonProps` | `Omit<UnstyledIconButtonProps, 'children'>` | Additional props to pass to the close button | - |
111
114
  | `fullscreen` | `boolean` | Whether the modal should take up the full screen height | `false` |
112
115
 
116
+ When `footer` is provided, the primary and secondary button props are not available. Build your footer actions directly inside the custom footer content instead.
117
+
113
118
  \* use this to detect if the modal has been opened or closed, index 0 indicates open state and -1 indicates closed state
114
119
 
115
120
  ### `ModalImage` Props
@@ -371,6 +376,52 @@ const CustomContentModal = () => {
371
376
  };
372
377
  ```
373
378
 
379
+ ### Sticky Custom Footer
380
+
381
+ Replace the built-in buttons with a custom sticky footer when you need custom layouts or button arrangements:
382
+
383
+ <Canvas of={Stories.StickyCustomFooter} />
384
+
385
+ ```tsx
386
+ const StickyCustomFooterModal = () => {
387
+ const modalRef = useRef<BottomSheetModal>(null);
388
+
389
+ return (
390
+ <>
391
+ <Button onPress={() => modalRef.current?.present()}>Show Custom Footer Modal</Button>
392
+
393
+ <Modal
394
+ ref={modalRef}
395
+ heading="Update billing details"
396
+ description="Use a custom sticky footer when you need horizontal actions or extra decoration."
397
+ footer={
398
+ <Flex direction="row" spacing="md" pt="250">
399
+ <Button
400
+ variant="outline"
401
+ colorScheme="functional"
402
+ onPress={() => modalRef.current?.dismiss()}
403
+ style={{ flex: 1 }}
404
+ >
405
+ Cancel
406
+ </Button>
407
+ <Button onPress={() => modalRef.current?.dismiss()} style={{ flex: 1 }}>
408
+ Save changes
409
+ </Button>
410
+ </Flex>
411
+ }
412
+ footerStyle={{
413
+ boxShadow: '0px -6px 12px rgba(16, 24, 40, 0.12)',
414
+ }}
415
+ >
416
+ <Box gap="200">
417
+ <BodyText>Review the changes before saving.</BodyText>
418
+ </Box>
419
+ </Modal>
420
+ </>
421
+ );
422
+ };
423
+ ```
424
+
374
425
  ### Loading State
375
426
 
376
427
  Show a loading spinner while processing:
@@ -1,10 +1,25 @@
1
1
  import { BottomSheetProps } from '../BottomSheet';
2
- import { ModalCommonProps } from './Modal.shared.types';
2
+ import {
3
+ ModalButtonFooterProps,
4
+ ModalCommonBaseProps,
5
+ ModalCustomFooterProps,
6
+ } from './Modal.shared.types';
3
7
 
4
- interface ModalProps extends Omit<BottomSheetProps, 'children'>, ModalCommonProps {
5
- fullscreen?: boolean;
6
- closeOnPrimaryButtonPress?: boolean;
7
- closeOnSecondaryButtonPress?: boolean;
8
- }
8
+ type ModalBaseProps = Omit<BottomSheetProps, 'children'> &
9
+ ModalCommonBaseProps & {
10
+ fullscreen?: boolean;
11
+ };
12
+
13
+ type ModalProps =
14
+ | (ModalBaseProps &
15
+ ModalButtonFooterProps & {
16
+ closeOnPrimaryButtonPress?: boolean;
17
+ closeOnSecondaryButtonPress?: boolean;
18
+ })
19
+ | (ModalBaseProps &
20
+ ModalCustomFooterProps & {
21
+ closeOnPrimaryButtonPress?: never;
22
+ closeOnSecondaryButtonPress?: never;
23
+ });
9
24
 
10
25
  export default ModalProps;
@@ -1,9 +1,9 @@
1
1
  import { ReactNode } from 'react';
2
- import { ViewProps } from 'react-native';
2
+ import { StyleProp, ViewProps, ViewStyle } from 'react-native';
3
3
  import { ButtonWithoutChildrenProps } from '../Button/Button.props';
4
4
  import { UnstyledIconButtonProps } from '../UnstyledIconButton';
5
5
 
6
- export interface ModalCommonProps {
6
+ export interface ModalCommonBaseProps {
7
7
  loading?: boolean;
8
8
  image?: ReactNode;
9
9
  showCloseButton?: boolean;
@@ -13,12 +13,31 @@ export interface ModalCommonProps {
13
13
  description?: string;
14
14
  stickyFooter?: boolean;
15
15
  children?: ViewProps['children'];
16
+ onPressCloseButton?: () => void;
17
+ closeButtonProps?: Omit<UnstyledIconButtonProps, 'children'>;
18
+ }
19
+
20
+ export interface ModalButtonFooterProps {
16
21
  onPressPrimaryButton?: () => void;
17
22
  primaryButtonText?: string;
18
23
  onPressSecondaryButton?: () => void;
19
24
  secondaryButtonText?: string;
20
- onPressCloseButton?: () => void;
21
25
  primaryButtonProps?: Omit<ButtonWithoutChildrenProps, 'children'>;
22
26
  secondaryButtonProps?: Omit<ButtonWithoutChildrenProps, 'children'>;
23
- closeButtonProps?: Omit<UnstyledIconButtonProps, 'children'>;
27
+ footer?: never;
28
+ footerStyle?: StyleProp<ViewStyle>;
29
+ }
30
+
31
+ export interface ModalCustomFooterProps {
32
+ footer: ReactNode;
33
+ footerStyle?: StyleProp<ViewStyle>;
34
+ onPressPrimaryButton?: never;
35
+ primaryButtonText?: never;
36
+ onPressSecondaryButton?: never;
37
+ secondaryButtonText?: never;
38
+ primaryButtonProps?: never;
39
+ secondaryButtonProps?: never;
24
40
  }
41
+
42
+ export type ModalCommonProps = ModalCommonBaseProps &
43
+ (ModalButtonFooterProps | ModalCustomFooterProps);
@@ -5,9 +5,10 @@ import { Modal, ModalImage } from '.';
5
5
  import pigs from '../../../docs/assets/pigs.png';
6
6
  import { ViewWrap } from '../../../docs/components';
7
7
  import { BodyText } from '../BodyText';
8
- import { BottomSheetModal } from '../BottomSheet';
8
+ import { BottomSheetModal, BottomSheetModalProvider } from '../BottomSheet';
9
9
  import { Box } from '../Box';
10
10
  import { Button } from '../Button';
11
+ import { Flex } from '../Flex';
11
12
 
12
13
  const meta = {
13
14
  title: 'Stories / Modal',
@@ -164,6 +165,56 @@ export const WithCustomContent = () => {
164
165
  );
165
166
  };
166
167
 
168
+ export const StickyCustomFooter = () => {
169
+ const modalRef = useRef<BottomSheetModal>(null);
170
+
171
+ const openModal = () => {
172
+ modalRef.current?.present();
173
+ };
174
+
175
+ const closeModal = () => {
176
+ modalRef.current?.dismiss();
177
+ };
178
+
179
+ return (
180
+ <View style={Platform.OS === 'web' ? { width: 400, height: 400 } : {}}>
181
+ <ViewWrap>
182
+ <Button onPress={openModal}>Open Modal</Button>
183
+
184
+ <Modal
185
+ ref={modalRef}
186
+ heading="Update billing details"
187
+ description="Review the changes below, then save or discard them using a custom sticky footer."
188
+ onPressCloseButton={closeModal}
189
+ footer={
190
+ <Flex direction="row" spacing="md" pt="250">
191
+ <Button
192
+ variant="outline"
193
+ colorScheme="functional"
194
+ onPress={closeModal}
195
+ style={{ flex: 1 }}
196
+ >
197
+ Cancel
198
+ </Button>
199
+ <Button onPress={closeModal} style={{ flex: 1 }}>
200
+ Save changes
201
+ </Button>
202
+ </Flex>
203
+ }
204
+ footerStyle={{
205
+ boxShadow: '0px -6px 12px rgba(16, 24, 40, 0.12)',
206
+ }}
207
+ >
208
+ <Box gap="200">
209
+ <BodyText>Billing address: 14 Park Street, Bristol</BodyText>
210
+ <BodyText>Preferred payment day: 15th of each month</BodyText>
211
+ </Box>
212
+ </Modal>
213
+ </ViewWrap>
214
+ </View>
215
+ );
216
+ };
217
+
167
218
  export const Loading = () => {
168
219
  const modalRef = useRef<BottomSheetModal>(null);
169
220
 
@@ -239,3 +290,116 @@ export const FullscreenModal: Story = {
239
290
  );
240
291
  },
241
292
  };
293
+
294
+ export const NoStickyFooter: Story = {
295
+ render: () => {
296
+ const modalRef = useRef<BottomSheetModal>(null);
297
+
298
+ const openModal = () => {
299
+ modalRef.current?.present();
300
+ };
301
+
302
+ const closeModal = () => {
303
+ modalRef.current?.dismiss();
304
+ };
305
+
306
+ return (
307
+ <View style={Platform.OS === 'web' ? { width: 400, height: 400 } : {}}>
308
+ <ViewWrap>
309
+ <Button onPress={openModal}>Open Modal</Button>
310
+
311
+ <Modal
312
+ ref={modalRef}
313
+ heading="Modal Heading"
314
+ description="This is a modal description without a sticky footer."
315
+ onPressCloseButton={closeModal}
316
+ primaryButtonText="Primary"
317
+ onPressPrimaryButton={closeModal}
318
+ secondaryButtonText="Cancel"
319
+ onPressSecondaryButton={closeModal}
320
+ stickyFooter={false}
321
+ >
322
+ <Box gap="200">
323
+ <BodyText>This is a modal with content.</BodyText>
324
+ <BodyText>You can swipe it up and down to close.</BodyText>
325
+ </Box>
326
+ </Modal>
327
+ </ViewWrap>
328
+ </View>
329
+ );
330
+ },
331
+ };
332
+
333
+ export const NoFooter: Story = {
334
+ render: () => {
335
+ const modalRef = useRef<BottomSheetModal>(null);
336
+
337
+ const openModal = () => {
338
+ modalRef.current?.present();
339
+ };
340
+
341
+ const closeModal = () => {
342
+ modalRef.current?.dismiss();
343
+ };
344
+
345
+ return (
346
+ <View style={Platform.OS === 'web' ? { width: 400, height: 400 } : {}}>
347
+ <ViewWrap>
348
+ <Button onPress={openModal}>Open Modal</Button>
349
+
350
+ <Modal
351
+ ref={modalRef}
352
+ heading="Modal Heading"
353
+ description="This is a modal description without a footer."
354
+ onPressCloseButton={closeModal}
355
+ >
356
+ <Box gap="200">
357
+ <BodyText>This is a modal with content.</BodyText>
358
+ <BodyText>You can swipe it up and down to close.</BodyText>
359
+ </Box>
360
+ </Modal>
361
+ </ViewWrap>
362
+ </View>
363
+ );
364
+ },
365
+ };
366
+
367
+ export const NoSafeArea: Story = {
368
+ render: () => {
369
+ const modalRef = useRef<BottomSheetModal>(null);
370
+
371
+ const openModal = () => {
372
+ modalRef.current?.present();
373
+ };
374
+
375
+ const closeModal = () => {
376
+ modalRef.current?.dismiss();
377
+ };
378
+
379
+ return (
380
+ <BottomSheetModalProvider useSafeAreaInsets={false}>
381
+ <View style={Platform.OS === 'web' ? { width: 400, height: 400 } : {}}>
382
+ <ViewWrap>
383
+ <Button onPress={openModal}>Open Modal</Button>
384
+
385
+ <Modal
386
+ ref={modalRef}
387
+ heading="Modal Heading"
388
+ description="This is a modal description without safe area insets."
389
+ onPressCloseButton={closeModal}
390
+ primaryButtonText="Primary"
391
+ onPressPrimaryButton={closeModal}
392
+ secondaryButtonText="Cancel"
393
+ onPressSecondaryButton={closeModal}
394
+ >
395
+ <Box gap="200">
396
+ <BodyText>This is a modal with content.</BodyText>
397
+ <BodyText>You can swipe it up and down to close.</BodyText>
398
+ </Box>
399
+ </Modal>
400
+ </ViewWrap>
401
+ </View>
402
+ </BottomSheetModalProvider>
403
+ );
404
+ },
405
+ };
@@ -6,9 +6,10 @@ import {
6
6
  } from '@gorhom/bottom-sheet';
7
7
  import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
8
8
  import { CloseMediumIcon } from '@utilitywarehouse/hearth-react-native-icons';
9
- import { useCallback, useImperativeHandle, useMemo, useRef } from 'react';
10
- import { AccessibilityInfo, Platform, View, findNodeHandle } from 'react-native';
9
+ import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
10
+ import { AccessibilityInfo, LayoutChangeEvent, Platform, View, findNodeHandle } from 'react-native';
11
11
  import { StyleSheet } from 'react-native-unistyles';
12
+ import { useTheme } from '../../hooks';
12
13
  import { BodyText } from '../BodyText';
13
14
  import { BottomSheetModal, BottomSheetScrollView } from '../BottomSheet';
14
15
  import { useBottomSheetContext } from '../BottomSheet/BottomSheet.context';
@@ -38,15 +39,19 @@ const Modal = ({
38
39
  loadingDescription,
39
40
  fullscreen = false,
40
41
  image,
42
+ footer,
43
+ footerStyle,
41
44
  primaryButtonProps,
42
45
  secondaryButtonProps,
43
46
  closeButtonProps,
44
47
  stickyFooter = true,
45
48
  ...props
46
49
  }: ModalProps) => {
50
+ const theme = useTheme();
47
51
  const bottomSheetModalRef = useRef<BottomSheetModal>(null);
48
52
  const viewRef = useRef<View>(null);
49
53
  const scrollViewRef = useRef<BottomSheetScrollViewMethods>(null);
54
+ const [stickyFooterHeight, setStickyFooterHeight] = useState(0);
50
55
  const { useSafeAreaInsets } = useBottomSheetContext();
51
56
 
52
57
  useImperativeHandle(ref, () => ({
@@ -100,45 +105,57 @@ const Modal = ({
100
105
  }
101
106
  }, [closeOnSecondaryButtonPress, onPressSecondaryButton]);
102
107
 
103
- const noButtons = !onPressPrimaryButton && !onPressSecondaryButton;
108
+ const handleStickyFooterLayout = useCallback((event: LayoutChangeEvent) => {
109
+ const nextHeight = Math.ceil(event.nativeEvent.layout.height);
110
+
111
+ setStickyFooterHeight(currentHeight =>
112
+ currentHeight === nextHeight ? currentHeight : nextHeight
113
+ );
114
+ }, []);
115
+
116
+ const hasPrimaryButton = !!(onPressPrimaryButton && primaryButtonText);
117
+ const hasSecondaryButton = !!(onPressSecondaryButton && secondaryButtonText);
118
+ const hasFooter = !!footer || hasPrimaryButton || hasSecondaryButton;
119
+ const shouldShowFooter = !loading && hasFooter;
104
120
 
105
121
  styles.useVariants({
106
122
  loading,
107
- bothButtons: !!(onPressPrimaryButton && onPressSecondaryButton),
108
- noButtons,
123
+ noButtons: !shouldShowFooter,
109
124
  stickyFooter,
110
125
  showHandle: props.showHandle,
111
126
  useSafeAreaInsets,
112
127
  });
113
128
 
114
- const footer = useMemo(
115
- () => (
116
- <View style={styles.footer}>
117
- {onPressPrimaryButton && primaryButtonText ? (
118
- <Button
119
- onPress={handlePrimaryButtonPress}
120
- text={primaryButtonText}
121
- {...primaryButtonProps}
122
- variant={(primaryButtonProps?.variant as 'solid') ?? 'solid'}
123
- colorScheme={(primaryButtonProps?.colorScheme as 'highlight') ?? 'highlight'}
124
- />
125
- ) : null}
126
- {onPressSecondaryButton && secondaryButtonText ? (
127
- <Button
128
- onPress={handleSecondaryButtonPress}
129
- text={secondaryButtonText}
130
- {...secondaryButtonProps}
131
- variant={(secondaryButtonProps?.variant as 'outline') ?? 'outline'}
132
- colorScheme={(secondaryButtonProps?.colorScheme as 'functional') ?? 'functional'}
133
- />
134
- ) : null}
135
- </View>
136
- ),
129
+ const footerContent = useMemo(
130
+ () =>
131
+ footer ?? (
132
+ <View style={styles.footer}>
133
+ {hasPrimaryButton ? (
134
+ <Button
135
+ onPress={handlePrimaryButtonPress}
136
+ text={primaryButtonText}
137
+ {...primaryButtonProps}
138
+ variant={(primaryButtonProps?.variant as 'solid') ?? 'solid'}
139
+ colorScheme={(primaryButtonProps?.colorScheme as 'highlight') ?? 'highlight'}
140
+ />
141
+ ) : null}
142
+ {hasSecondaryButton ? (
143
+ <Button
144
+ onPress={handleSecondaryButtonPress}
145
+ text={secondaryButtonText}
146
+ {...secondaryButtonProps}
147
+ variant={(secondaryButtonProps?.variant as 'outline') ?? 'outline'}
148
+ colorScheme={(secondaryButtonProps?.colorScheme as 'functional') ?? 'functional'}
149
+ />
150
+ ) : null}
151
+ </View>
152
+ ),
137
153
  [
154
+ footer,
138
155
  handlePrimaryButtonPress,
139
156
  handleSecondaryButtonPress,
140
- onPressPrimaryButton,
141
- onPressSecondaryButton,
157
+ hasPrimaryButton,
158
+ hasSecondaryButton,
142
159
  primaryButtonProps,
143
160
  primaryButtonText,
144
161
  secondaryButtonProps,
@@ -209,38 +226,64 @@ const Modal = ({
209
226
  </View>
210
227
  ) : null}
211
228
  {children}
212
- {!stickyFooter && !noButtons ? footer : null}
229
+ {!stickyFooter && shouldShowFooter ? (
230
+ <View style={footerStyle}>{footerContent}</View>
231
+ ) : null}
213
232
  </View>
214
233
  )}
215
234
  </>
216
235
  );
217
236
 
218
237
  const renderFooter = useCallback(
219
- (props: BottomSheetFooterProps) => (
220
- <BottomSheetFooter {...props}>
221
- <View style={styles.footerWrap}>{footer}</View>
238
+ (bottomSheetFooterProps: BottomSheetFooterProps) => (
239
+ <BottomSheetFooter {...bottomSheetFooterProps}>
240
+ <View onLayout={handleStickyFooterLayout} style={[styles.footerWrap, footerStyle]}>
241
+ {footerContent}
242
+ </View>
222
243
  </BottomSheetFooter>
223
244
  ),
224
- [footer]
245
+ [footerContent, footerStyle, handleStickyFooterLayout]
225
246
  );
226
247
 
227
248
  return (
228
- <BottomSheetModal
229
- ref={bottomSheetModalRef}
230
- enableDynamicSizing={true}
231
- snapPoints={image || fullscreen ? ['90%'] : props.snapPoints}
232
- showHandle={typeof loading !== 'undefined' && loading ? false : props.showHandle}
233
- accessible={false}
234
- style={styles.modal}
235
- footerComponent={stickyFooter && !noButtons ? renderFooter : undefined}
236
- {...props}
237
- onChange={handleChange}
238
- >
239
- {loading ? <View style={styles.loadingTop} /> : null}
240
- <BottomSheetScrollView contentContainerStyle={styles.scrollView} ref={scrollViewRef}>
241
- {content}
242
- </BottomSheetScrollView>
243
- </BottomSheetModal>
249
+ <>
250
+ {stickyFooter && shouldShowFooter && stickyFooterHeight === 0 ? (
251
+ <View
252
+ accessible={false}
253
+ importantForAccessibility="no-hide-descendants"
254
+ pointerEvents="none"
255
+ style={styles.footerMeasurementContainer}
256
+ >
257
+ <View onLayout={handleStickyFooterLayout} style={[styles.footerWrap, footerStyle]}>
258
+ {footerContent}
259
+ </View>
260
+ </View>
261
+ ) : null}
262
+ <BottomSheetModal
263
+ ref={bottomSheetModalRef}
264
+ enableDynamicSizing={true}
265
+ snapPoints={image || fullscreen ? ['90%'] : props.snapPoints}
266
+ showHandle={typeof loading !== 'undefined' && loading ? false : props.showHandle}
267
+ accessible={false}
268
+ style={styles.modal}
269
+ footerComponent={stickyFooter && shouldShowFooter ? renderFooter : undefined}
270
+ {...props}
271
+ onChange={handleChange}
272
+ >
273
+ {loading ? <View style={styles.loadingTop} /> : null}
274
+ <BottomSheetScrollView
275
+ contentContainerStyle={[
276
+ styles.scrollView,
277
+ stickyFooter && shouldShowFooter && stickyFooterHeight > 0
278
+ ? { paddingBottom: stickyFooterHeight + theme.components.modal.gap }
279
+ : null,
280
+ ]}
281
+ ref={scrollViewRef}
282
+ >
283
+ {content}
284
+ </BottomSheetScrollView>
285
+ </BottomSheetModal>
286
+ </>
244
287
  );
245
288
  };
246
289
 
@@ -262,14 +305,6 @@ const styles = StyleSheet.create((theme, rt) => ({
262
305
  scrollView: {
263
306
  flex: 1,
264
307
  variants: {
265
- bothButtons: {
266
- true: {
267
- paddingBottom: 166,
268
- },
269
- false: {
270
- paddingBottom: 102,
271
- },
272
- },
273
308
  noButtons: {
274
309
  true: {
275
310
  paddingBottom: theme.components.modal.padding,
@@ -287,31 +322,10 @@ const styles = StyleSheet.create((theme, rt) => ({
287
322
  },
288
323
  },
289
324
  compoundVariants: [
290
- {
291
- bothButtons: true,
292
- useSafeAreaInsets: true,
293
- styles: {
294
- paddingBottom:
295
- 166 +
296
- rt.insets.bottom -
297
- theme.components.modal.padding +
298
- theme.components.bottomSheet.padding,
299
- },
300
- },
301
- {
302
- bothButtons: false,
303
- useSafeAreaInsets: true,
304
- styles: {
305
- paddingBottom:
306
- 102 +
307
- rt.insets.bottom -
308
- theme.components.modal.padding +
309
- theme.components.bottomSheet.padding,
310
- },
311
- },
312
325
  {
313
326
  noButtons: true,
314
327
  useSafeAreaInsets: true,
328
+ stickyFooter: false,
315
329
  styles: {
316
330
  paddingBottom:
317
331
  rt.insets.bottom +
@@ -372,6 +386,12 @@ const styles = StyleSheet.create((theme, rt) => ({
372
386
  footer: {
373
387
  gap: theme.components.modal.action.gap,
374
388
  },
389
+ footerMeasurementContainer: {
390
+ left: 0,
391
+ opacity: 0,
392
+ position: 'absolute',
393
+ right: 0,
394
+ },
375
395
  footerWrap: {
376
396
  backgroundColor: theme.color.surface.neutral.strong,
377
397
  paddingHorizontal: theme.components.bottomSheet.padding,
@@ -31,6 +31,8 @@ const Modal = ({
31
31
  loadingHeading = 'Loading...',
32
32
  fullscreen = false,
33
33
  image,
34
+ footer,
35
+ footerStyle,
34
36
  primaryButtonProps,
35
37
  secondaryButtonProps,
36
38
  closeButtonProps,
@@ -91,7 +93,32 @@ const Modal = ({
91
93
  }
92
94
  };
93
95
 
94
- const noButtons = !onPressPrimaryButton && !onPressSecondaryButton;
96
+ const hasPrimaryButton = !!(onPressPrimaryButton && primaryButtonText);
97
+ const hasSecondaryButton = !!(onPressSecondaryButton && secondaryButtonText);
98
+ const hasFooter = !!footer || hasPrimaryButton || hasSecondaryButton;
99
+
100
+ const footerContent = footer ?? (
101
+ <View style={styles.footer}>
102
+ {hasPrimaryButton ? (
103
+ <Button
104
+ onPress={handlePrimaryButtonPress}
105
+ text={primaryButtonText}
106
+ {...primaryButtonProps}
107
+ variant={(primaryButtonProps?.variant as 'solid') ?? 'solid'}
108
+ colorScheme={(primaryButtonProps?.colorScheme as 'highlight') ?? 'highlight'}
109
+ />
110
+ ) : null}
111
+ {hasSecondaryButton ? (
112
+ <Button
113
+ onPress={handleSecondaryButtonPress}
114
+ text={secondaryButtonText}
115
+ {...secondaryButtonProps}
116
+ variant={(secondaryButtonProps?.variant as 'outline') ?? 'outline'}
117
+ colorScheme={(secondaryButtonProps?.colorScheme as 'functional') ?? 'functional'}
118
+ />
119
+ ) : null}
120
+ </View>
121
+ );
95
122
 
96
123
  const content = (
97
124
  <>
@@ -152,28 +179,7 @@ const Modal = ({
152
179
  </View>
153
180
  ) : null}
154
181
  {children}
155
- {!noButtons ? (
156
- <View style={styles.footer}>
157
- {onPressPrimaryButton && primaryButtonText ? (
158
- <Button
159
- onPress={handlePrimaryButtonPress}
160
- text={primaryButtonText}
161
- {...primaryButtonProps}
162
- variant={(primaryButtonProps?.variant as 'solid') ?? 'solid'}
163
- colorScheme={(primaryButtonProps?.colorScheme as 'highlight') ?? 'highlight'}
164
- />
165
- ) : null}
166
- {onPressSecondaryButton && secondaryButtonText ? (
167
- <Button
168
- onPress={handleSecondaryButtonPress}
169
- text={secondaryButtonText}
170
- {...secondaryButtonProps}
171
- variant={(secondaryButtonProps?.variant as 'outline') ?? 'outline'}
172
- colorScheme={(secondaryButtonProps?.colorScheme as 'functional') ?? 'functional'}
173
- />
174
- ) : null}
175
- </View>
176
- ) : null}
182
+ {hasFooter ? <View style={footerStyle}>{footerContent}</View> : null}
177
183
  </View>
178
184
  )}
179
185
  </>