@utilitywarehouse/hearth-react-native 0.24.0 → 0.26.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 (90) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-lint.log +13 -13
  3. package/CHANGELOG.md +72 -0
  4. package/build/components/DatePicker/DatePickerCalendar.js +4 -9
  5. package/build/components/Modal/Modal.d.ts +1 -1
  6. package/build/components/Modal/Modal.js +30 -7
  7. package/build/components/Modal/Modal.props.d.ts +4 -2
  8. package/build/components/TimePicker/TimePicker.d.ts +6 -0
  9. package/build/components/TimePicker/TimePicker.js +78 -0
  10. package/build/components/TimePicker/TimePicker.props.d.ts +45 -0
  11. package/build/components/TimePicker/TimePicker.props.js +1 -0
  12. package/build/components/TimePicker/TimePickerView.d.ts +12 -0
  13. package/build/components/TimePicker/TimePickerView.js +130 -0
  14. package/build/components/TimePicker/TimePickerWheel.d.ts +8 -0
  15. package/build/components/TimePicker/TimePickerWheel.js +78 -0
  16. package/build/components/{DatePicker/time-picker/wheel-web.d.ts → TimePicker/TimePickerWheel.web.d.ts} +4 -4
  17. package/build/components/TimePicker/TimePickerWheel.web.js +122 -0
  18. package/build/components/TimePicker/index.d.ts +6 -0
  19. package/build/components/TimePicker/index.js +3 -0
  20. package/build/components/TimePickerInput/TimePickerInput.d.ts +6 -0
  21. package/build/components/TimePickerInput/TimePickerInput.js +127 -0
  22. package/build/components/TimePickerInput/TimePickerInput.props.d.ts +52 -0
  23. package/build/components/TimePickerInput/TimePickerInput.props.js +1 -0
  24. package/build/components/TimePickerInput/TimePickerInputDoneButton.d.ts +8 -0
  25. package/build/components/TimePickerInput/TimePickerInputDoneButton.js +19 -0
  26. package/build/components/TimePickerInput/TimePickerInputDoneButton.web.d.ts +5 -0
  27. package/build/components/TimePickerInput/TimePickerInputDoneButton.web.js +5 -0
  28. package/build/components/TimePickerInput/index.d.ts +2 -0
  29. package/build/components/TimePickerInput/index.js +1 -0
  30. package/build/components/index.d.ts +2 -0
  31. package/build/components/index.js +2 -0
  32. package/docs/components/AllComponents.web.tsx +30 -0
  33. package/package.json +3 -2
  34. package/src/components/DatePicker/DatePickerCalendar.tsx +30 -13
  35. package/src/components/Modal/Modal.docs.mdx +9 -3
  36. package/src/components/Modal/Modal.props.ts +4 -2
  37. package/src/components/Modal/Modal.tsx +44 -7
  38. package/src/components/TimePicker/TimePicker.docs.mdx +84 -0
  39. package/src/components/TimePicker/TimePicker.figma.tsx +29 -0
  40. package/src/components/TimePicker/TimePicker.props.ts +45 -0
  41. package/src/components/TimePicker/TimePicker.stories.tsx +85 -0
  42. package/src/components/TimePicker/TimePicker.tsx +150 -0
  43. package/src/components/TimePicker/TimePickerView.tsx +216 -0
  44. package/src/components/TimePicker/TimePickerWheel.tsx +154 -0
  45. package/src/components/TimePicker/TimePickerWheel.web.tsx +217 -0
  46. package/src/components/TimePicker/index.ts +8 -0
  47. package/src/components/TimePickerInput/TimePickerInput.docs.mdx +135 -0
  48. package/src/components/TimePickerInput/TimePickerInput.figma.tsx +34 -0
  49. package/src/components/TimePickerInput/TimePickerInput.props.ts +55 -0
  50. package/src/components/TimePickerInput/TimePickerInput.stories.tsx +175 -0
  51. package/src/components/TimePickerInput/TimePickerInput.tsx +283 -0
  52. package/src/components/TimePickerInput/TimePickerInputDoneButton.tsx +42 -0
  53. package/src/components/TimePickerInput/TimePickerInputDoneButton.web.tsx +7 -0
  54. package/src/components/TimePickerInput/index.ts +2 -0
  55. package/src/components/index.ts +2 -0
  56. package/build/components/DatePicker/TimePicker.d.ts +0 -3
  57. package/build/components/DatePicker/TimePicker.js +0 -84
  58. package/build/components/DatePicker/time-picker/animated-math.d.ts +0 -4
  59. package/build/components/DatePicker/time-picker/animated-math.js +0 -19
  60. package/build/components/DatePicker/time-picker/period-native.d.ts +0 -6
  61. package/build/components/DatePicker/time-picker/period-native.js +0 -17
  62. package/build/components/DatePicker/time-picker/period-picker.d.ts +0 -6
  63. package/build/components/DatePicker/time-picker/period-picker.js +0 -10
  64. package/build/components/DatePicker/time-picker/period-web.d.ts +0 -6
  65. package/build/components/DatePicker/time-picker/period-web.js +0 -21
  66. package/build/components/DatePicker/time-picker/wheel-native.d.ts +0 -8
  67. package/build/components/DatePicker/time-picker/wheel-native.js +0 -19
  68. package/build/components/DatePicker/time-picker/wheel-picker/index.d.ts +0 -2
  69. package/build/components/DatePicker/time-picker/wheel-picker/index.js +0 -2
  70. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker-item.d.ts +0 -16
  71. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker-item.js +0 -97
  72. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker.d.ts +0 -21
  73. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker.js +0 -88
  74. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker.style.d.ts +0 -23
  75. package/build/components/DatePicker/time-picker/wheel-picker/wheel-picker.style.js +0 -21
  76. package/build/components/DatePicker/time-picker/wheel-web.js +0 -146
  77. package/build/components/DatePicker/time-picker/wheel.d.ts +0 -8
  78. package/build/components/DatePicker/time-picker/wheel.js +0 -10
  79. package/src/components/DatePicker/TimePicker.tsx +0 -141
  80. package/src/components/DatePicker/time-picker/animated-math.ts +0 -33
  81. package/src/components/DatePicker/time-picker/period-native.tsx +0 -34
  82. package/src/components/DatePicker/time-picker/period-picker.tsx +0 -16
  83. package/src/components/DatePicker/time-picker/period-web.tsx +0 -36
  84. package/src/components/DatePicker/time-picker/wheel-native.tsx +0 -37
  85. package/src/components/DatePicker/time-picker/wheel-picker/index.ts +0 -3
  86. package/src/components/DatePicker/time-picker/wheel-picker/wheel-picker-item.tsx +0 -132
  87. package/src/components/DatePicker/time-picker/wheel-picker/wheel-picker.style.ts +0 -22
  88. package/src/components/DatePicker/time-picker/wheel-picker/wheel-picker.tsx +0 -200
  89. package/src/components/DatePicker/time-picker/wheel-web.tsx +0 -180
  90. package/src/components/DatePicker/time-picker/wheel.tsx +0 -18
@@ -107,9 +107,10 @@ The Modal component extends the `BottomSheetModal` component and accepts all of
107
107
  | `primaryButtonProps` | `Omit<ButtonWithoutChildrenProps, 'children'>` | Additional props to pass to the primary button (colorScheme defaults to 'highlight', variant to 'solid') | - |
108
108
  | `secondaryButtonProps` | `Omit<ButtonWithoutChildrenProps, 'children'>` | Additional props to pass to the secondary button (colorScheme defaults to 'functional', variant to 'outline') | - |
109
109
  | `closeButtonProps` | `Omit<UnstyledIconButtonProps, 'children'>` | Additional props to pass to the close button | - |
110
- | `fullscreen` | `boolean` | Whether the modal should take up the full screen height | `false` |
110
+ | `fullscreen` | `boolean` | Whether the modal should take up the full screen height. Only applies when `inNavModal` is `false` | `false` |
111
111
  | `inNavModal` | `boolean` | Renders the modal correctly when used inside a navigation modal | `false` |
112
112
  | `background` | `'default' \| 'brand'` | Sets the modal background. Only applies when `inNavModal` is `true` | `'default'` |
113
+ | `scrollable` | `boolean` | Whether the modal's content should be placed in a `ScrollView`. Only applies when `inNavModal` is `true` | `true` |
113
114
 
114
115
  \* use this to detect if the modal has been opened or closed, index 0 indicates open state and -1 indicates closed state
115
116
 
@@ -462,11 +463,16 @@ const AlertModal = () => {
462
463
 
463
464
  ### Modal In Navigation Modal
464
465
 
465
- When using the Modal component in a navigation context, you can set it to `inNavModal` mode, this will make it behave like a standard modal screen.
466
+ When wanting to use the Modal component in a navigation context using [React Navigation](https://reactnavigation.org/docs/modal), you can set `inNavModal` to `true` to make it behave like a standard modal screen.
467
+
468
+ Within React Navigation, you can set `presentation: 'modal'` in the screen's settings to have the Modal look and behave like a standard modal/bottom sheet, or you can set `presentation: 'fullScreenModal'` to have the Modal fill the entire screen.
469
+
470
+ When using `inNavModal`, by default the content will be rendered inside a `ScrollView` to ensure it is scrollable, especially on smaller devices or smaller modals. You can disable this by setting `scrollable={false}` if, for example, you need to center your content or add some custom content.
471
+
466
472
  Here's an example of how to implement this with custom close animations for Android:
467
473
 
468
474
  ```tsx
469
- import { useNavigation } from 'react-navigation/native';
475
+ import { useNavigation } from '@react-navigation/native';
470
476
  import { useCallback, useEffect, useRef } from 'react';
471
477
  import { Platform, StyleSheet, View } from 'react-native';
472
478
 
@@ -12,6 +12,7 @@ interface ModalPropsBase extends Omit<BottomSheetProps, 'children'> {
12
12
  loadingHeading?: string;
13
13
  description?: string;
14
14
  fullscreen?: boolean;
15
+ stickyFooter?: boolean;
15
16
  children?: ViewProps['children'];
16
17
  onPressPrimaryButton?: () => void;
17
18
  primaryButtonText?: string;
@@ -28,12 +29,13 @@ interface ModalPropsBase extends Omit<BottomSheetProps, 'children'> {
28
29
  type ModalProps =
29
30
  | (ModalPropsBase & {
30
31
  inNavModal?: false | undefined;
31
- stickyFooter?: boolean;
32
+ scrollable?: never;
32
33
  background?: never;
33
34
  })
34
35
  | (ModalPropsBase & {
35
36
  inNavModal: true;
36
- stickyFooter?: never;
37
+ fullscreen?: never;
38
+ scrollable?: boolean;
37
39
  background?: 'default' | 'brand';
38
40
  });
39
41
 
@@ -6,8 +6,8 @@ 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, useEffect, useImperativeHandle, useRef } from 'react';
10
- import { AccessibilityInfo, Platform, ScrollView, View, findNodeHandle } from 'react-native';
9
+ import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
10
+ import { AccessibilityInfo, Dimensions, Platform, ScrollView, View, findNodeHandle } from 'react-native';
11
11
  import Animated, {
12
12
  Easing,
13
13
  useAnimatedStyle,
@@ -51,6 +51,7 @@ const Modal = ({
51
51
  inNavModal = false,
52
52
  stickyFooter = true,
53
53
  background = 'default',
54
+ scrollable = true,
54
55
  ...props
55
56
  }: ModalProps) => {
56
57
  const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -61,6 +62,16 @@ const Modal = ({
61
62
  const pretendContentTranslateY = useSharedValue(20);
62
63
  const isBrandBackground = background === 'brand';
63
64
 
65
+ const [inNavModalHeight, setInNavModalHeight] = useState<number>();
66
+
67
+ const isNavModalFullScreen = useMemo(() => {
68
+ if (!inNavModalHeight || !inNavModal) return false;
69
+
70
+ const screenHeight = Dimensions.get('window').height;
71
+
72
+ return inNavModalHeight >= screenHeight;
73
+ }, [inNavModalHeight, inNavModal]);
74
+
64
75
  const triggerCloseAnimation = useCallback(() => {
65
76
  if (Platform.OS === 'android' && inNavModal) {
66
77
  pretendContentTranslateY.value = withTiming(20, {
@@ -173,6 +184,9 @@ const Modal = ({
173
184
  stickyFooter,
174
185
  showHandle: props.showHandle,
175
186
  background: isBrandBackground ? 'brand' : 'primary',
187
+ ...(inNavModal && {
188
+ fullscreen: isNavModalFullScreen,
189
+ }),
176
190
  });
177
191
 
178
192
  const footer = (
@@ -200,6 +214,8 @@ const Modal = ({
200
214
  </View>
201
215
  );
202
216
 
217
+ const InNavModalContainer = scrollable ? ScrollView : View;
218
+
203
219
  const content = (
204
220
  <>
205
221
  {loading ? (
@@ -275,8 +291,14 @@ const Modal = ({
275
291
  </View>
276
292
  </View>
277
293
  ) : null}
278
- {inNavModal ? <ScrollView style={{ flex: 1 }}>{children}</ScrollView> : children}
279
- {(!stickyFooter || inNavModal) && !noButtons ? footer : null}
294
+ {inNavModal && (
295
+ <InNavModalContainer style={{ flexGrow: stickyFooter ? 1 : 0 }}>
296
+ {children}
297
+ {!stickyFooter ? <View style={styles.inNavModalFooterContainer}>{footer}</View> : null}
298
+ </InNavModalContainer>
299
+ )}
300
+ {!inNavModal && children}
301
+ {((!stickyFooter && !inNavModal) || (inNavModal && stickyFooter)) && !noButtons ? footer : null}
280
302
  </View>
281
303
  )}
282
304
  </>
@@ -300,6 +322,9 @@ const Modal = ({
300
322
 
301
323
  return inNavModal ? (
302
324
  <View
325
+ onLayout={(e) => {
326
+ setInNavModalHeight(e.nativeEvent.layout.height);
327
+ }}
303
328
  style={{
304
329
  flex: 1,
305
330
  backgroundColor: theme.color.background[isBrandBackground ? 'brand' : 'primary'],
@@ -313,7 +338,9 @@ const Modal = ({
313
338
  <Animated.View
314
339
  style={[styles.inNavModalContainer, Platform.OS === 'android' && animatedInNavModalStyle]}
315
340
  >
316
- <View style={styles.inNavModalContent}>{content}</View>
341
+ <View style={styles.inNavModalContent}>
342
+ {content}
343
+ </View>
317
344
  </Animated.View>
318
345
  </View>
319
346
  ) : (
@@ -444,8 +471,6 @@ const styles = StyleSheet.create((theme, rt) => ({
444
471
  borderTopLeftRadius: theme.components.modal.borderRadius,
445
472
  borderTopRightRadius: theme.components.modal.borderRadius,
446
473
  backgroundColor: theme.color.surface.neutral.strong,
447
- gap: theme.components.modal.gap,
448
- padding: theme.components.modal.padding,
449
474
  paddingBottom: theme.components.modal.padding + rt.insets.bottom,
450
475
  variants: {
451
476
  background: {
@@ -454,8 +479,20 @@ const styles = StyleSheet.create((theme, rt) => ({
454
479
  backgroundColor: theme.color.background.brand,
455
480
  },
456
481
  },
482
+ fullscreen: {
483
+ true: {
484
+ padding: theme.components.modal.padding,
485
+ paddingTop: rt.insets.top,
486
+ },
487
+ false: {
488
+ padding: theme.components.modal.padding,
489
+ }
490
+ }
457
491
  },
458
492
  },
493
+ inNavModalFooterContainer: {
494
+ paddingTop: theme.components.modal.padding,
495
+ },
459
496
  androidContainer: {
460
497
  height: rt.insets.top + 18,
461
498
  paddingLeft: theme.components.modal.padding,
@@ -0,0 +1,84 @@
1
+ import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks';
2
+ import { Button, Center } from '../../';
3
+ import { BackToTopButton, UsageWrap, ViewFigmaButton } from '../../../docs/components';
4
+ import * as Stories from './TimePicker.stories';
5
+
6
+ <ViewFigmaButton url="https://www.figma.com/design/6NKZXZhFSExXrcbBgc6zTR/Hearth-Components---Tokens?node-id=10334-16770&t=Jg2fPJPQNzOyspmQ-4" />
7
+
8
+ <Meta title="Components / Time Picker" />
9
+
10
+ <BackToTopButton />
11
+
12
+ # Time Picker
13
+
14
+ `TimePicker` presents a wheel-based time selector inside a bottom sheet, letting people pick hours and minutes without leaving the current context. It supports 12-hour and 24-hour clocks and returns a JavaScript `Date` whenever the selection changes.
15
+
16
+ - [Playground](#playground)
17
+ - [Usage](#usage)
18
+ - [Props](#props)
19
+ - [Accessibility](#accessibility)
20
+
21
+ ## Playground
22
+
23
+ <Canvas of={Stories.Playground} />
24
+
25
+ <Controls of={Stories.Playground} />
26
+
27
+ ## Usage
28
+
29
+ Use the `TimePicker` with a ref to present the bottom sheet when people tap a trigger button.
30
+
31
+ <UsageWrap>
32
+ <Center>
33
+ <Button onPress={() => {}}>Pick a time</Button>
34
+ </Center>
35
+ </UsageWrap>
36
+
37
+ ```tsx
38
+ import { useRef, useState } from 'react';
39
+ import {
40
+ BottomSheetModalProvider,
41
+ Button,
42
+ TimePicker,
43
+ type DateType,
44
+ } from '@utilitywarehouse/hearth-react-native';
45
+
46
+ const BookingTime = () => {
47
+ const pickerRef = useRef(null);
48
+ const [time, setTime] = useState<DateType>();
49
+
50
+ return (
51
+ <BottomSheetModalProvider>
52
+ <Button onPress={() => pickerRef.current?.present()}>Choose time</Button>
53
+
54
+ <TimePicker
55
+ ref={pickerRef}
56
+ date={time}
57
+ onChange={({ date }) => setTime(date)}
58
+ onCancel={() => setTime(undefined)}
59
+ use12Hours={false}
60
+ />
61
+ </BottomSheetModalProvider>
62
+ );
63
+ };
64
+ ```
65
+
66
+ ## Props
67
+
68
+ `TimePicker` extends the `BottomSheetModal` component. The table below highlights the main props.
69
+
70
+ | Prop | Type | Default | Description |
71
+ | ---------------- | --------------------------------------- | ---------- | --------------------------------------------------------------- |
72
+ | `date` | `DateType` | `-` | Selected time value. |
73
+ | `timeZone` | `string` | `-` | IANA time zone identifier applied to the selected time. |
74
+ | `use12Hours` | `boolean` | `false` | Displays an AM/PM selector and formats hours from 1 to 12. |
75
+ | `minuteInterval` | `number` | `1` | Step interval for minutes shown in the picker. |
76
+ | `hideFooter` | `boolean` | `false` | Hides the Cancel/Ok actions. |
77
+ | `onChange` | `(payload: { date: DateType }) => void` | `-` | Fired whenever the selected time changes. |
78
+ | `onCancel` | `() => void` | `() => {}` | Fired when the cancel action is triggered. |
79
+ | `ref` | `Ref<BottomSheetModalMethods>` | `-` | Gives imperative access to present or dismiss the bottom sheet. |
80
+
81
+ ## Accessibility
82
+
83
+ - Screen readers announce the picker when the sheet opens and focus the wheel area on Android.
84
+ - Action buttons are exposed as standard buttons with clear labels.
@@ -0,0 +1,29 @@
1
+ import figma from '@figma/code-connect';
2
+ import { useRef, useState } from 'react';
3
+ import { BottomSheetModalProvider, Button, DateType, TimePicker } from '../';
4
+
5
+ figma.connect(
6
+ TimePicker,
7
+ 'https://www.figma.com/design/6NKZXZhFSExXrcbBgc6zTR/Hearth-Components---Tokens?node-id=10334-16770&t=Jg2fPJPQNzOyspmQ-4',
8
+ {
9
+ props: {},
10
+ example: props => {
11
+ const pickerRef = useRef(null);
12
+ const [time, setTime] = useState<DateType>();
13
+
14
+ return (
15
+ <BottomSheetModalProvider>
16
+ <Button onPress={() => pickerRef.current?.present()}>Choose time</Button>
17
+
18
+ <TimePicker
19
+ ref={pickerRef}
20
+ date={time}
21
+ onChange={({ date }) => setTime(date)}
22
+ onCancel={() => setTime(undefined)}
23
+ use12Hours={false}
24
+ />
25
+ </BottomSheetModalProvider>
26
+ );
27
+ },
28
+ }
29
+ );
@@ -0,0 +1,45 @@
1
+ import type { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
2
+ import type { Ref } from 'react';
3
+ import type { ViewStyle } from 'react-native';
4
+ import type { DateType, PickerOption } from '../DatePicker/DatePicker.props';
5
+
6
+ export interface TimePickerProps {
7
+ /**
8
+ * IANA time zone identifier applied when normalising and comparing times.
9
+ */
10
+ timeZone?: string;
11
+ /**
12
+ * Controlled time value.
13
+ */
14
+ date?: DateType;
15
+ /**
16
+ * Fired whenever a time is picked.
17
+ */
18
+ onChange?: (params: { date: DateType }) => void;
19
+ /**
20
+ * Display a 12-hour clock with AM/PM selector.
21
+ */
22
+ use12Hours?: boolean;
23
+ /**
24
+ * Step interval for minutes shown in the picker.
25
+ */
26
+ minuteInterval?: number;
27
+ /**
28
+ * Hide the footer actions.
29
+ */
30
+ hideFooter?: boolean;
31
+ /**
32
+ * Custom container styling for the time picker surface.
33
+ */
34
+ style?: ViewStyle;
35
+ /**
36
+ * Gives imperative access to the bottom sheet instance.
37
+ */
38
+ ref?: Ref<BottomSheetModalMethods<any>>;
39
+ /**
40
+ * Fired when the cancel action is triggered.
41
+ */
42
+ onCancel?: () => void;
43
+ }
44
+
45
+ export type { DateType, PickerOption };
@@ -0,0 +1,85 @@
1
+ import { Meta, StoryObj } from '@storybook/react-native';
2
+ import { useRef, useState } from 'react';
3
+ import { Platform, View } from 'react-native';
4
+ import { DateType, TimePicker } from '.';
5
+ import { ViewWrap } from '../../../docs/components';
6
+ import { BottomSheetModal } from '../BottomSheet';
7
+ import { Button } from '../Button';
8
+
9
+ const meta = {
10
+ title: 'Stories / TimePicker',
11
+ component: TimePicker,
12
+ parameters: {
13
+ layout: 'centered',
14
+ },
15
+ argTypes: {
16
+ use12Hours: {
17
+ control: 'boolean',
18
+ description: 'Display a 12-hour clock with AM/PM selector',
19
+ defaultValue: false,
20
+ },
21
+ minuteInterval: {
22
+ control: 'number',
23
+ description: 'Step interval for minutes shown in the picker',
24
+ defaultValue: 1,
25
+ },
26
+ },
27
+ args: {
28
+ use12Hours: false,
29
+ minuteInterval: 1,
30
+ },
31
+ } satisfies Meta<typeof TimePicker>;
32
+
33
+ export default meta;
34
+
35
+ type Story = StoryObj<typeof meta>;
36
+ type StoryArgs = Story['args'];
37
+
38
+ export const Playground: Story = {
39
+ render: (args: StoryArgs) => {
40
+ const [selected, setSelected] = useState<DateType>();
41
+ const modalRef = useRef<BottomSheetModal>(null);
42
+
43
+ return (
44
+ <View style={Platform.OS === 'web' ? { width: 400, height: 400 } : {}}>
45
+ <ViewWrap>
46
+ <Button onPress={() => modalRef.current?.present()}>Show Time Picker</Button>
47
+ <TimePicker
48
+ ref={modalRef}
49
+ date={selected}
50
+ use12Hours={args.use12Hours}
51
+ minuteInterval={args.minuteInterval}
52
+ onChange={({ date }) => setSelected(date)}
53
+ onCancel={() => setSelected(undefined)}
54
+ />
55
+ </ViewWrap>
56
+ </View>
57
+ );
58
+ },
59
+ };
60
+
61
+ export const TwelveHour: Story = {
62
+ args: {
63
+ use12Hours: true,
64
+ },
65
+ render: (args: StoryArgs) => {
66
+ const [selected, setSelected] = useState<DateType>();
67
+ const modalRef = useRef<BottomSheetModal>(null);
68
+
69
+ return (
70
+ <View style={Platform.OS === 'web' ? { width: 400, height: 400 } : {}}>
71
+ <ViewWrap>
72
+ <Button onPress={() => modalRef.current?.present()}>Show 12-hour Time Picker</Button>
73
+ <TimePicker
74
+ ref={modalRef}
75
+ date={selected}
76
+ use12Hours={args.use12Hours}
77
+ minuteInterval={args.minuteInterval}
78
+ onChange={({ date }) => setSelected(date)}
79
+ onCancel={() => setSelected(undefined)}
80
+ />
81
+ </ViewWrap>
82
+ </View>
83
+ );
84
+ },
85
+ };
@@ -0,0 +1,150 @@
1
+ import dayjs from 'dayjs';
2
+ import timezone from 'dayjs/plugin/timezone';
3
+ import utc from 'dayjs/plugin/utc';
4
+ import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
5
+ import { AccessibilityInfo, findNodeHandle, Platform, View as RNView } from 'react-native';
6
+ import { StyleSheet } from 'react-native-unistyles';
7
+ import { BottomSheetModal, BottomSheetView } from '../BottomSheet';
8
+ import { Button } from '../Button';
9
+ import type { DateType, TimePickerProps } from './TimePicker.props';
10
+ import TimePickerView from './TimePickerView';
11
+
12
+ dayjs.extend(utc);
13
+ dayjs.extend(timezone);
14
+
15
+ type FooterProps = {
16
+ onCancel: () => void;
17
+ onConfirm: () => void;
18
+ };
19
+
20
+ const Footer = ({ onCancel, onConfirm }: FooterProps) => {
21
+ return (
22
+ <RNView style={styles.footer} testID="footer">
23
+ <Button variant="ghost" colorScheme="functional" onPress={onCancel}>
24
+ Cancel
25
+ </Button>
26
+ <Button variant="ghost" colorScheme="functional" onPress={onConfirm}>
27
+ Ok
28
+ </Button>
29
+ </RNView>
30
+ );
31
+ };
32
+
33
+ const TimePicker = ({
34
+ timeZone,
35
+ date,
36
+ onChange,
37
+ use12Hours,
38
+ minuteInterval,
39
+ hideFooter,
40
+ style,
41
+ ref,
42
+ onCancel = () => {},
43
+ }: TimePickerProps) => {
44
+ dayjs.tz.setDefault(timeZone);
45
+ dayjs.locale('en');
46
+
47
+ const modalRef = useRef<BottomSheetModal>(null);
48
+ const pickerViewRef = useRef<RNView>(null);
49
+
50
+ useImperativeHandle(ref, () => modalRef.current as BottomSheetModal);
51
+
52
+ const [currentDate, setCurrentDate] = useState<DateType>(() => {
53
+ return date ? dayjs.tz(date, timeZone) : dayjs().tz(timeZone);
54
+ });
55
+
56
+ useEffect(() => {
57
+ const nextDate = date ? dayjs.tz(date, timeZone) : dayjs().tz(timeZone);
58
+ const isSameMinute = dayjs(currentDate).isSame(nextDate, 'minute');
59
+
60
+ if (!isSameMinute) {
61
+ setCurrentDate(nextDate);
62
+ }
63
+ }, [currentDate, date, timeZone]);
64
+
65
+ const closeTimePicker = useCallback(() => {
66
+ modalRef.current?.close();
67
+ }, []);
68
+
69
+ const handleSelectDate = useCallback(
70
+ (selectedDate: DateType) => {
71
+ const newDate = dayjs.tz(selectedDate ?? currentDate, timeZone);
72
+ if (!dayjs(currentDate).isSame(newDate, 'minute')) {
73
+ setCurrentDate(newDate);
74
+ }
75
+ onChange?.({ date: newDate ? dayjs(newDate).toDate() : newDate });
76
+ },
77
+ [currentDate, onChange, timeZone]
78
+ );
79
+
80
+ const handleCancel = useCallback(() => {
81
+ onCancel?.();
82
+ closeTimePicker();
83
+ }, [closeTimePicker, onCancel]);
84
+
85
+ const handleConfirm = useCallback(() => {
86
+ closeTimePicker();
87
+ }, [closeTimePicker]);
88
+
89
+ const handleChange = useCallback((index: number) => {
90
+ if (index > -1) {
91
+ setTimeout(() => {
92
+ AccessibilityInfo.announceForAccessibility('Time picker opened.');
93
+
94
+ const targetRef = pickerViewRef.current;
95
+ if (targetRef) {
96
+ const nodeHandle = findNodeHandle(targetRef);
97
+ if (nodeHandle) {
98
+ AccessibilityInfo.setAccessibilityFocus(nodeHandle);
99
+ }
100
+ }
101
+ }, 50);
102
+ }
103
+ }, []);
104
+
105
+ const contentStyle = useMemo(() => [styles.container, style], [style]);
106
+
107
+ return (
108
+ <BottomSheetModal
109
+ ref={modalRef}
110
+ onChange={handleChange}
111
+ accessible={false}
112
+ enableContentPanningGesture={false}
113
+ >
114
+ <BottomSheetView>
115
+ <RNView
116
+ ref={pickerViewRef}
117
+ accessible={Platform.OS === 'android' ? true : undefined}
118
+ accessibilityLabel={Platform.OS === 'android' ? 'Time picker' : undefined}
119
+ importantForAccessibility={Platform.OS === 'android' ? 'yes' : 'auto'}
120
+ style={contentStyle}
121
+ >
122
+ <TimePickerView
123
+ currentDate={currentDate}
124
+ onSelectDate={handleSelectDate}
125
+ timeZone={timeZone}
126
+ use12Hours={use12Hours}
127
+ minuteInterval={minuteInterval}
128
+ />
129
+ {!hideFooter ? <Footer onCancel={handleCancel} onConfirm={handleConfirm} /> : null}
130
+ </RNView>
131
+ </BottomSheetView>
132
+ </BottomSheetModal>
133
+ );
134
+ };
135
+
136
+ TimePicker.displayName = 'TimePicker';
137
+
138
+ const styles = StyleSheet.create(theme => ({
139
+ container: {
140
+ backgroundColor: theme.color.background.secondary,
141
+ gap: theme.components.datePicker.calendar.gap,
142
+ },
143
+ footer: {
144
+ flexDirection: 'row',
145
+ gap: theme.components.datePicker.calendar.footer.gap,
146
+ justifyContent: 'flex-end',
147
+ },
148
+ }));
149
+
150
+ export default TimePicker;