@utilitywarehouse/hearth-react-native 0.25.0 → 0.27.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 (42) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-lint.log +15 -15
  3. package/CHANGELOG.md +71 -0
  4. package/build/components/Banner/Banner.js +12 -1
  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/PillGroup/Pill.js +0 -1
  9. package/build/components/PillGroup/PillGroup.js +4 -1
  10. package/build/components/SegmentedControl/SegmentedControl.context.d.ts +14 -0
  11. package/build/components/SegmentedControl/SegmentedControl.context.js +9 -0
  12. package/build/components/SegmentedControl/SegmentedControl.d.ts +6 -0
  13. package/build/components/SegmentedControl/SegmentedControl.js +196 -0
  14. package/build/components/SegmentedControl/SegmentedControl.props.d.ts +18 -0
  15. package/build/components/SegmentedControl/SegmentedControl.props.js +1 -0
  16. package/build/components/SegmentedControl/SegmentedControlOption.d.ts +18 -0
  17. package/build/components/SegmentedControl/SegmentedControlOption.js +122 -0
  18. package/build/components/SegmentedControl/SegmentedControlOption.props.d.ts +12 -0
  19. package/build/components/SegmentedControl/SegmentedControlOption.props.js +1 -0
  20. package/build/components/SegmentedControl/index.d.ts +4 -0
  21. package/build/components/SegmentedControl/index.js +2 -0
  22. package/build/components/index.d.ts +1 -0
  23. package/build/components/index.js +1 -0
  24. package/docs/changelog.mdx +136 -0
  25. package/docs/components/AllComponents.web.tsx +14 -0
  26. package/package.json +3 -3
  27. package/src/components/Banner/Banner.tsx +12 -1
  28. package/src/components/Modal/Modal.docs.mdx +9 -3
  29. package/src/components/Modal/Modal.props.ts +4 -2
  30. package/src/components/Modal/Modal.tsx +44 -7
  31. package/src/components/PillGroup/Pill.tsx +0 -1
  32. package/src/components/PillGroup/PillGroup.tsx +4 -0
  33. package/src/components/SegmentedControl/SegmentedControl.context.ts +22 -0
  34. package/src/components/SegmentedControl/SegmentedControl.docs.mdx +90 -0
  35. package/src/components/SegmentedControl/SegmentedControl.figma.tsx +40 -0
  36. package/src/components/SegmentedControl/SegmentedControl.props.ts +20 -0
  37. package/src/components/SegmentedControl/SegmentedControl.stories.tsx +77 -0
  38. package/src/components/SegmentedControl/SegmentedControl.tsx +257 -0
  39. package/src/components/SegmentedControl/SegmentedControlOption.props.ts +14 -0
  40. package/src/components/SegmentedControl/SegmentedControlOption.tsx +213 -0
  41. package/src/components/SegmentedControl/index.ts +4 -0
  42. package/src/components/index.ts +1 -0
@@ -1,4 +1,4 @@
1
1
 
2
- > @utilitywarehouse/hearth-react-native@0.25.0 build /home/runner/work/hearth/hearth/packages/react-native
2
+ > @utilitywarehouse/hearth-react-native@0.27.0 build /home/runner/work/hearth/hearth/packages/react-native
3
3
  > tsc
4
4
 
@@ -1,5 +1,5 @@
1
1
 
2
- > @utilitywarehouse/hearth-react-native@0.25.0 lint /home/runner/work/hearth/hearth/packages/react-native
2
+ > @utilitywarehouse/hearth-react-native@0.27.0 lint /home/runner/work/hearth/hearth/packages/react-native
3
3
  > TIMING=1 eslint .
4
4
 
5
5
 
@@ -31,8 +31,8 @@
31
31
  78:8 warning React Hook useEffect has a missing dependency: 'formFieldContext'. Either include it or remove the dependency array react-hooks/exhaustive-deps
32
32
 
33
33
  /home/runner/work/hearth/hearth/packages/react-native/src/components/Modal/Modal.tsx
34
- 75:6 warning React Hook useCallback has an unnecessary dependency: 'Platform.OS'. Either exclude it or remove the dependency array. Outer scope values like 'Platform.OS' aren't valid dependencies because mutating them doesn't re-render the component react-hooks/exhaustive-deps
35
- 291:5 warning React Hook useCallback has a missing dependency: 'footer'. Either include it or remove the dependency array react-hooks/exhaustive-deps
34
+ 86:6 warning React Hook useCallback has an unnecessary dependency: 'Platform.OS'. Either exclude it or remove the dependency array. Outer scope values like 'Platform.OS' aren't valid dependencies because mutating them doesn't re-render the component react-hooks/exhaustive-deps
35
+ 313:5 warning React Hook useCallback has a missing dependency: 'footer'. Either include it or remove the dependency array react-hooks/exhaustive-deps
36
36
 
37
37
  /home/runner/work/hearth/hearth/packages/react-native/src/components/Modal/Modal.web.tsx
38
38
  66:6 warning React Hook useCallback has an unnecessary dependency: 'Platform.OS'. Either exclude it or remove the dependency array. Outer scope values like 'Platform.OS' aren't valid dependencies because mutating them doesn't re-render the component react-hooks/exhaustive-deps
@@ -58,15 +58,15 @@
58
58
 
59
59
  ✖ 25 problems (0 errors, 25 warnings)
60
60
 
61
- Rule | Time (ms) | Relative
62
- :-----------------------------------------|----------:|--------:
63
- @typescript-eslint/no-unused-vars | 1535.077 | 58.6%
64
- react-hooks/exhaustive-deps | 186.011 | 7.1%
65
- no-global-assign | 92.645 | 3.5%
66
- react-hooks/rules-of-hooks | 90.323 | 3.4%
67
- no-misleading-character-class | 64.601 | 2.5%
68
- no-unexpected-multiline | 47.391 | 1.8%
69
- @typescript-eslint/ban-ts-comment | 39.233 | 1.5%
70
- @typescript-eslint/triple-slash-reference | 36.020 | 1.4%
71
- no-useless-escape | 31.051 | 1.2%
72
- @typescript-eslint/no-unused-expressions | 28.768 | 1.1%
61
+ Rule | Time (ms) | Relative
62
+ :---------------------------------|----------:|--------:
63
+ @typescript-eslint/no-unused-vars | 1531.354 | 62.0%
64
+ react-hooks/exhaustive-deps | 115.862 | 4.7%
65
+ no-global-assign | 82.745 | 3.3%
66
+ react-hooks/rules-of-hooks | 81.422 | 3.3%
67
+ @typescript-eslint/ban-ts-comment | 54.976 | 2.2%
68
+ no-misleading-character-class | 43.812 | 1.8%
69
+ no-unexpected-multiline | 39.804 | 1.6%
70
+ no-fallthrough | 33.437 | 1.4%
71
+ no-regex-spaces | 28.539 | 1.2%
72
+ no-shadow-restricted-names | 25.451 | 1.0%
package/CHANGELOG.md CHANGED
@@ -1,5 +1,76 @@
1
1
  # @utilitywarehouse/hearth-react-native
2
2
 
3
+ ## 0.27.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#987](https://github.com/utilitywarehouse/hearth/pull/987) [`eb962d2`](https://github.com/utilitywarehouse/hearth/commit/eb962d2f33b63fa3aeda0b291fd41ace90d04c41) Thanks [@jordmccord](https://github.com/jordmccord)! - 🌟 [FEATURE]: Add `SegmentedControl` and `SegmentedControlOption` components.
8
+
9
+ This introduces a new segmented control component for switching between a small set of related options.
10
+ The component includes controlled and uncontrolled usage, size variants (`sm`, `md`), animated selected indicator movement, and improved accessibility semantics for screen readers.
11
+
12
+ **Components affected**:
13
+ - `SegmentedControl`
14
+ - `SegmentedControlOption`
15
+
16
+ **Developer changes**:
17
+
18
+ Import and compose the new components as follows:
19
+
20
+ ```tsx
21
+ import { SegmentedControl, SegmentedControlOption } from '@utilitywarehouse/hearth-react-native';
22
+
23
+ <SegmentedControl defaultValue="day" size="sm">
24
+ <SegmentedControlOption value="day">Day</SegmentedControlOption>
25
+ <SegmentedControlOption value="week">Week</SegmentedControlOption>
26
+ <SegmentedControlOption value="month">Month</SegmentedControlOption>
27
+ </SegmentedControl>;
28
+ ```
29
+
30
+ ### Patch Changes
31
+
32
+ - [#989](https://github.com/utilitywarehouse/hearth/pull/989) [`c97122e`](https://github.com/utilitywarehouse/hearth/commit/c97122eb429ec4adef656fb245a9256a5619df61) Thanks [@jordmccord](https://github.com/jordmccord)! - 🐛 [FIX]: Ensure horizontal `Banner` fills available width when `onPress` is not provided.
33
+
34
+ Fixed a layout issue where a horizontal `Banner` without `onPress` could fail to stretch correctly within its parent container.
35
+
36
+ **Components affected**:
37
+ - `Banner`
38
+
39
+ **Developer changes**:
40
+
41
+ No changes required.
42
+
43
+ ## 0.26.0
44
+
45
+ ### Minor Changes
46
+
47
+ - [#981](https://github.com/utilitywarehouse/hearth/pull/981) [`df56387`](https://github.com/utilitywarehouse/hearth/commit/df563872e6bf040d419f6c7fce2343ebe560edb9) Thanks [@declanelcocks](https://github.com/declanelcocks)! - 🌟 [ENHANCEMENT]: Improve `Modal` behaviour when used inside a React Navigation modal (`inNavModal`).
48
+
49
+ The following improvements have been made to the `Modal` component when used in a navigation context with `inNavModal={true}`:
50
+ - **`scrollable` prop**: Content is now rendered inside a `ScrollView` by default. Set `scrollable={false}` to opt out, for example when you need to centre content or use a custom layout.
51
+ - **`stickyFooter` support**: The `stickyFooter` prop now works correctly in `inNavModal` mode.
52
+ - **Auto full-screen detection**: When the modal fills the entire screen (e.g. with `presentation: 'fullScreenModal'`), the `fullscreen` style is applied automatically. The `fullscreen` prop is no longer available when `inNavModal` is `true`.
53
+
54
+ **Components affected**:
55
+ - `Modal`
56
+
57
+ **Developer changes**:
58
+
59
+ No changes are required for existing usage. If you were passing `fullscreen` alongside `inNavModal={true}`, remove the `fullscreen` prop — full-screen styling is now detected automatically:
60
+
61
+ ```diff
62
+ - <Modal inNavModal fullscreen>
63
+ + <Modal inNavModal>
64
+ ```
65
+
66
+ To disable the default `ScrollView` wrapping in `inNavModal` mode:
67
+
68
+ ```tsx
69
+ <Modal inNavModal scrollable={false}>
70
+ {/* custom layout */}
71
+ </Modal>
72
+ ```
73
+
3
74
  ## 0.25.0
4
75
 
5
76
  ### Minor Changes
@@ -11,7 +11,7 @@ import { UnstyledIconButton } from '../UnstyledIconButton';
11
11
  import BannerContext from './Banner.context';
12
12
  const Banner = ({ icon, iconContainerVariant = 'subtle', iconContainerSize = 'md', iconContainerColor = 'pig', illustration, image, heading, description, direction = 'horizontal', link, button, onPress, onClose, variant = 'subtle', style, ...props }) => {
13
13
  const hasIllustration = Boolean(illustration);
14
- styles.useVariants({ direction, hasIllustration });
14
+ styles.useVariants({ direction, hasIllustration, isPressable: Boolean(onPress) });
15
15
  const context = useMemo(() => ({
16
16
  direction,
17
17
  }), [direction]);
@@ -66,6 +66,10 @@ const styles = StyleSheet.create(theme => ({
66
66
  true: {},
67
67
  false: {},
68
68
  },
69
+ isPressable: {
70
+ true: {},
71
+ false: {},
72
+ },
69
73
  },
70
74
  compoundVariants: [
71
75
  {
@@ -82,6 +86,13 @@ const styles = StyleSheet.create(theme => ({
82
86
  alignItems: 'center',
83
87
  },
84
88
  },
89
+ {
90
+ direction: 'horizontal',
91
+ isPressable: false,
92
+ styles: {
93
+ flex: 1,
94
+ },
95
+ },
85
96
  ],
86
97
  },
87
98
  media: {
@@ -3,5 +3,5 @@ import ModalProps from './Modal.props';
3
3
  type Modal<T = any> = BottomSheetModalMethods<T> & {
4
4
  triggerCloseAnimation?: () => void;
5
5
  };
6
- declare const Modal: ({ ref, children, heading, description, showCloseButton, primaryButtonText, secondaryButtonText, onPressPrimaryButton, onPressCloseButton, onPressSecondaryButton, closeOnPrimaryButtonPress, closeOnSecondaryButtonPress, loading, loadingHeading, fullscreen, image, primaryButtonProps, secondaryButtonProps, closeButtonProps, inNavModal, stickyFooter, background, ...props }: ModalProps) => import("react/jsx-runtime").JSX.Element;
6
+ declare const Modal: ({ ref, children, heading, description, showCloseButton, primaryButtonText, secondaryButtonText, onPressPrimaryButton, onPressCloseButton, onPressSecondaryButton, closeOnPrimaryButtonPress, closeOnSecondaryButtonPress, loading, loadingHeading, fullscreen, image, primaryButtonProps, secondaryButtonProps, closeButtonProps, inNavModal, stickyFooter, background, scrollable, ...props }: ModalProps) => import("react/jsx-runtime").JSX.Element;
7
7
  export default Modal;
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { BottomSheetFooter, } from '@gorhom/bottom-sheet';
3
3
  import { CloseMediumIcon } from '@utilitywarehouse/hearth-react-native-icons';
4
- import { useCallback, useEffect, useImperativeHandle, useRef } from 'react';
5
- import { AccessibilityInfo, Platform, ScrollView, View, findNodeHandle } from 'react-native';
4
+ import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
5
+ import { AccessibilityInfo, Dimensions, Platform, ScrollView, View, findNodeHandle } from 'react-native';
6
6
  import Animated, { Easing, useAnimatedStyle, useSharedValue, withDelay, withTiming, } from 'react-native-reanimated';
7
7
  import { StyleSheet } from 'react-native-unistyles';
8
8
  import { useTheme } from '../../hooks';
@@ -13,7 +13,7 @@ import { Button } from '../Button';
13
13
  import { Heading } from '../Heading';
14
14
  import { Spinner } from '../Spinner';
15
15
  import { UnstyledIconButton } from '../UnstyledIconButton';
16
- const Modal = ({ ref, children, heading, description, showCloseButton = true, primaryButtonText, secondaryButtonText, onPressPrimaryButton, onPressCloseButton, onPressSecondaryButton, closeOnPrimaryButtonPress = true, closeOnSecondaryButtonPress = true, loading, loadingHeading = 'Loading...', fullscreen = false, image, primaryButtonProps, secondaryButtonProps, closeButtonProps, inNavModal = false, stickyFooter = true, background = 'default', ...props }) => {
16
+ const Modal = ({ ref, children, heading, description, showCloseButton = true, primaryButtonText, secondaryButtonText, onPressPrimaryButton, onPressCloseButton, onPressSecondaryButton, closeOnPrimaryButtonPress = true, closeOnSecondaryButtonPress = true, loading, loadingHeading = 'Loading...', fullscreen = false, image, primaryButtonProps, secondaryButtonProps, closeButtonProps, inNavModal = false, stickyFooter = true, background = 'default', scrollable = true, ...props }) => {
17
17
  const bottomSheetModalRef = useRef(null);
18
18
  const viewRef = useRef(null);
19
19
  const scrollViewRef = useRef(null);
@@ -21,6 +21,13 @@ const Modal = ({ ref, children, heading, description, showCloseButton = true, pr
21
21
  const backgroundOpacity = useSharedValue(0);
22
22
  const pretendContentTranslateY = useSharedValue(20);
23
23
  const isBrandBackground = background === 'brand';
24
+ const [inNavModalHeight, setInNavModalHeight] = useState();
25
+ const isNavModalFullScreen = useMemo(() => {
26
+ if (!inNavModalHeight || !inNavModal)
27
+ return false;
28
+ const screenHeight = Dimensions.get('window').height;
29
+ return inNavModalHeight >= screenHeight;
30
+ }, [inNavModalHeight, inNavModal]);
24
31
  const triggerCloseAnimation = useCallback(() => {
25
32
  if (Platform.OS === 'android' && inNavModal) {
26
33
  pretendContentTranslateY.value = withTiming(20, {
@@ -107,9 +114,13 @@ const Modal = ({ ref, children, heading, description, showCloseButton = true, pr
107
114
  stickyFooter,
108
115
  showHandle: props.showHandle,
109
116
  background: isBrandBackground ? 'brand' : 'primary',
117
+ ...(inNavModal && {
118
+ fullscreen: isNavModalFullScreen,
119
+ }),
110
120
  });
111
121
  const footer = (_jsxs(View, { style: styles.footer, children: [onPressPrimaryButton && primaryButtonText ? (_jsx(Button, { onPress: handlePrimaryButtonPress, text: primaryButtonText, inverted: isBrandBackground && inNavModal, ...primaryButtonProps, variant: primaryButtonProps?.variant ?? 'solid', colorScheme: primaryButtonProps?.colorScheme ?? 'highlight' })) : null, onPressSecondaryButton && secondaryButtonText ? (_jsx(Button, { onPress: handleSecondaryButtonPress, text: secondaryButtonText, inverted: isBrandBackground && inNavModal, ...secondaryButtonProps, variant: secondaryButtonProps?.variant ?? 'outline', colorScheme: secondaryButtonProps?.colorScheme ?? 'functional' })) : null] }));
112
- const content = (_jsx(_Fragment, { children: loading ? (_jsxs(View, { style: styles.loadingContainer, accessible: Platform.OS === 'android' ? true : undefined, accessibilityLabel: Platform.OS === 'android' ? 'Loading' : undefined, screenReaderFocusable: true, ref: viewRef, children: [_jsx(Spinner, { size: "lg", color: isBrandBackground && inNavModal ? theme.color.icon.inverted : undefined }), _jsx(Heading, { size: "lg", textAlign: "center", inverted: isBrandBackground && inNavModal, children: loadingHeading })] })) : (_jsxs(View, { style: styles.container, accessible: Platform.OS === 'android' ? true : undefined, accessibilityLabel: Platform.OS === 'android' ? 'Modal content' : undefined, screenReaderFocusable: true, ref: viewRef, children: [_jsxs(View, { style: styles.header, children: [_jsxs(View, { style: styles.headerTextContent, children: [heading && !image ? (_jsx(Heading, { size: "lg", accessible: true, inverted: isBrandBackground && inNavModal, children: heading })) : null, description && !image ? (_jsx(BodyText, { accessible: true, inverted: isBrandBackground && inNavModal, children: description })) : null] }), showCloseButton ? (_jsx(UnstyledIconButton, { icon: CloseMediumIcon, onPress: handleCloseButtonPress, accessibilityLabel: "Close modal", inverted: isBrandBackground && inNavModal, ...closeButtonProps })) : null] }), image ? (_jsxs(View, { style: styles.imageContainer, children: [image, _jsxs(View, { style: styles.textContent, children: [heading ? (_jsx(Heading, { size: "lg", textAlign: "center", accessible: true, inverted: isBrandBackground && inNavModal, children: heading })) : null, description ? (_jsx(BodyText, { textAlign: "center", accessible: true, inverted: isBrandBackground && inNavModal, children: description })) : null] })] })) : null, inNavModal ? _jsx(ScrollView, { style: { flex: 1 }, children: children }) : children, (!stickyFooter || inNavModal) && !noButtons ? footer : null] })) }));
122
+ const InNavModalContainer = scrollable ? ScrollView : View;
123
+ const content = (_jsx(_Fragment, { children: loading ? (_jsxs(View, { style: styles.loadingContainer, accessible: Platform.OS === 'android' ? true : undefined, accessibilityLabel: Platform.OS === 'android' ? 'Loading' : undefined, screenReaderFocusable: true, ref: viewRef, children: [_jsx(Spinner, { size: "lg", color: isBrandBackground && inNavModal ? theme.color.icon.inverted : undefined }), _jsx(Heading, { size: "lg", textAlign: "center", inverted: isBrandBackground && inNavModal, children: loadingHeading })] })) : (_jsxs(View, { style: styles.container, accessible: Platform.OS === 'android' ? true : undefined, accessibilityLabel: Platform.OS === 'android' ? 'Modal content' : undefined, screenReaderFocusable: true, ref: viewRef, children: [_jsxs(View, { style: styles.header, children: [_jsxs(View, { style: styles.headerTextContent, children: [heading && !image ? (_jsx(Heading, { size: "lg", accessible: true, inverted: isBrandBackground && inNavModal, children: heading })) : null, description && !image ? (_jsx(BodyText, { accessible: true, inverted: isBrandBackground && inNavModal, children: description })) : null] }), showCloseButton ? (_jsx(UnstyledIconButton, { icon: CloseMediumIcon, onPress: handleCloseButtonPress, accessibilityLabel: "Close modal", inverted: isBrandBackground && inNavModal, ...closeButtonProps })) : null] }), image ? (_jsxs(View, { style: styles.imageContainer, children: [image, _jsxs(View, { style: styles.textContent, children: [heading ? (_jsx(Heading, { size: "lg", textAlign: "center", accessible: true, inverted: isBrandBackground && inNavModal, children: heading })) : null, description ? (_jsx(BodyText, { textAlign: "center", accessible: true, inverted: isBrandBackground && inNavModal, children: description })) : null] })] })) : null, inNavModal && (_jsxs(InNavModalContainer, { style: { flexGrow: stickyFooter ? 1 : 0 }, children: [children, !stickyFooter ? _jsx(View, { style: styles.inNavModalFooterContainer, children: footer }) : null] })), !inNavModal && children, ((!stickyFooter && !inNavModal) || (inNavModal && stickyFooter)) && !noButtons ? footer : null] })) }));
113
124
  const renderFooter = useCallback((props) => (_jsx(BottomSheetFooter, { ...props, children: _jsx(View, { style: styles.footerWrap, children: footer }) })), [
114
125
  onPressPrimaryButton,
115
126
  primaryButtonText,
@@ -118,7 +129,9 @@ const Modal = ({ ref, children, heading, description, showCloseButton = true, pr
118
129
  primaryButtonProps,
119
130
  secondaryButtonProps,
120
131
  ]);
121
- return inNavModal ? (_jsxs(View, { style: {
132
+ return inNavModal ? (_jsxs(View, { onLayout: (e) => {
133
+ setInNavModalHeight(e.nativeEvent.layout.height);
134
+ }, style: {
122
135
  flex: 1,
123
136
  backgroundColor: theme.color.background[isBrandBackground ? 'brand' : 'primary'],
124
137
  }, children: [Platform.OS === 'android' ? (_jsx(Animated.View, { style: [styles.androidContainer, animatedBackgroundStyle], children: _jsx(Animated.View, { style: [styles.pretendContent, animatedPretendContentStyle] }) })) : null, _jsx(Animated.View, { style: [styles.inNavModalContainer, Platform.OS === 'android' && animatedInNavModalStyle], children: _jsx(View, { style: styles.inNavModalContent, children: content }) })] })) : (_jsxs(BottomSheetModal, { ref: bottomSheetModalRef, enableDynamicSizing: true, snapPoints: image || fullscreen ? ['90%'] : props.snapPoints, showHandle: typeof loading !== 'undefined' && loading ? false : props.showHandle, accessible: false, style: styles.modal, footerComponent: stickyFooter && !noButtons ? renderFooter : undefined, ...props, onChange: handleChange, children: [loading ? _jsx(View, { style: styles.loadingTop }) : null, _jsx(BottomSheetScrollView, { contentContainerStyle: styles.scrollView, ref: scrollViewRef, children: content })] }));
@@ -227,8 +240,6 @@ const styles = StyleSheet.create((theme, rt) => ({
227
240
  borderTopLeftRadius: theme.components.modal.borderRadius,
228
241
  borderTopRightRadius: theme.components.modal.borderRadius,
229
242
  backgroundColor: theme.color.surface.neutral.strong,
230
- gap: theme.components.modal.gap,
231
- padding: theme.components.modal.padding,
232
243
  paddingBottom: theme.components.modal.padding + rt.insets.bottom,
233
244
  variants: {
234
245
  background: {
@@ -237,8 +248,20 @@ const styles = StyleSheet.create((theme, rt) => ({
237
248
  backgroundColor: theme.color.background.brand,
238
249
  },
239
250
  },
251
+ fullscreen: {
252
+ true: {
253
+ padding: theme.components.modal.padding,
254
+ paddingTop: rt.insets.top,
255
+ },
256
+ false: {
257
+ padding: theme.components.modal.padding,
258
+ }
259
+ }
240
260
  },
241
261
  },
262
+ inNavModalFooterContainer: {
263
+ paddingTop: theme.components.modal.padding,
264
+ },
242
265
  androidContainer: {
243
266
  height: rt.insets.top + 18,
244
267
  paddingLeft: theme.components.modal.padding,
@@ -11,6 +11,7 @@ interface ModalPropsBase extends Omit<BottomSheetProps, 'children'> {
11
11
  loadingHeading?: string;
12
12
  description?: string;
13
13
  fullscreen?: boolean;
14
+ stickyFooter?: boolean;
14
15
  children?: ViewProps['children'];
15
16
  onPressPrimaryButton?: () => void;
16
17
  primaryButtonText?: string;
@@ -25,11 +26,12 @@ interface ModalPropsBase extends Omit<BottomSheetProps, 'children'> {
25
26
  }
26
27
  type ModalProps = (ModalPropsBase & {
27
28
  inNavModal?: false | undefined;
28
- stickyFooter?: boolean;
29
+ scrollable?: never;
29
30
  background?: never;
30
31
  }) | (ModalPropsBase & {
31
32
  inNavModal: true;
32
- stickyFooter?: never;
33
+ fullscreen?: never;
34
+ scrollable?: boolean;
33
35
  background?: 'default' | 'brand';
34
36
  });
35
37
  export default ModalProps;
@@ -35,7 +35,6 @@ const styles = StyleSheet.create(theme => ({
35
35
  _hover: {
36
36
  backgroundColor: theme.color.interactive.neutral.surface.subtle.hover,
37
37
  },
38
- // '_focus-visible': theme.helpers.focusVisible,
39
38
  },
40
39
  variants: {
41
40
  active: {
@@ -20,10 +20,13 @@ export const PillGroup = ({ children, value, multiple = false, wrap = true, onCh
20
20
  }
21
21
  },
22
22
  }), [normalizedValue, multiple, onChange]);
23
- return (_jsx(PillGroupContext.Provider, { value: contextValue, children: wrap ? (_jsx(Box, { style: [styles.group, styles.wrap, style], ...props, children: children })) : (_jsx(ScrollView, { horizontal: true, contentContainerStyle: [styles.group, style], showsHorizontalScrollIndicator: false, ...props, children: children })) }));
23
+ return (_jsx(PillGroupContext.Provider, { value: contextValue, children: wrap ? (_jsx(Box, { style: [styles.group, styles.wrap, style], ...props, children: children })) : (_jsx(ScrollView, { horizontal: true, style: styles.scrollView, contentContainerStyle: [styles.group, style], showsHorizontalScrollIndicator: false, ...props, children: children })) }));
24
24
  };
25
25
  PillGroup.displayName = 'PillGroup';
26
26
  const styles = StyleSheet.create(theme => ({
27
+ scrollView: {
28
+ flexGrow: 0,
29
+ },
27
30
  group: {
28
31
  flexDirection: 'row',
29
32
  gap: theme.components.pill.group.gap,
@@ -0,0 +1,14 @@
1
+ export type SegmentedControlContextValue = {
2
+ value?: string;
3
+ select: (value: string) => void;
4
+ disabled?: boolean;
5
+ size: 'sm' | 'md';
6
+ registerOptionLayout: (value: string, layout: {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ }) => void;
12
+ };
13
+ export declare const SegmentedControlContext: import("react").Context<SegmentedControlContextValue | null>;
14
+ export declare const useSegmentedControlContext: () => SegmentedControlContextValue;
@@ -0,0 +1,9 @@
1
+ import { createContext, useContext } from 'react';
2
+ export const SegmentedControlContext = createContext(null);
3
+ export const useSegmentedControlContext = () => {
4
+ const context = useContext(SegmentedControlContext);
5
+ if (!context) {
6
+ throw new Error('SegmentedControlOption must be used within SegmentedControl');
7
+ }
8
+ return context;
9
+ };
@@ -0,0 +1,6 @@
1
+ import type SegmentedControlProps from './SegmentedControl.props';
2
+ declare const SegmentedControl: {
3
+ ({ value: controlledValue, defaultValue, onValueChange, size, disabled, children, style, ...props }: SegmentedControlProps): import("react/jsx-runtime").JSX.Element;
4
+ displayName: string;
5
+ };
6
+ export default SegmentedControl;
@@ -0,0 +1,196 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Children, isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { View } from 'react-native';
4
+ import Animated, { Easing, useAnimatedStyle, useReducedMotion, useSharedValue, withTiming, } from 'react-native-reanimated';
5
+ import { StyleSheet } from 'react-native-unistyles';
6
+ import { useStyleProps } from '../../hooks';
7
+ import { SegmentedControlContext } from './SegmentedControl.context';
8
+ const Indicator = Animated.createAnimatedComponent(View);
9
+ const GROUP_BORDER_WIDTH = 1;
10
+ const SegmentedControl = ({ value: controlledValue, defaultValue, onValueChange, size = 'sm', disabled = false, children, style, ...props }) => {
11
+ const { computedStyles, remainingProps } = useStyleProps(props);
12
+ const isReducedMotion = useReducedMotion();
13
+ const indicatorPositionOffset = GROUP_BORDER_WIDTH;
14
+ const optionValues = useMemo(() => {
15
+ const values = [];
16
+ const walk = (node) => {
17
+ Children.forEach(node, child => {
18
+ if (!isValidElement(child))
19
+ return;
20
+ const childType = child.type;
21
+ const childProps = child.props;
22
+ if (childType?.displayName === 'SegmentedControlOption' &&
23
+ typeof childProps?.value === 'string') {
24
+ values.push(childProps.value);
25
+ }
26
+ if (childProps?.children) {
27
+ walk(childProps.children);
28
+ }
29
+ });
30
+ };
31
+ walk(children);
32
+ return values;
33
+ }, [children]);
34
+ const optionValuesKey = useMemo(() => optionValues.join('|'), [optionValues]);
35
+ const optionValuesRef = useRef(optionValues);
36
+ useEffect(() => {
37
+ optionValuesRef.current = optionValues;
38
+ }, [optionValues]);
39
+ const getInitialValue = () => {
40
+ if (controlledValue !== undefined)
41
+ return controlledValue;
42
+ if (defaultValue !== undefined)
43
+ return defaultValue;
44
+ return optionValues[0];
45
+ };
46
+ const [uncontrolledValue, setUncontrolledValue] = useState(getInitialValue);
47
+ useEffect(() => {
48
+ if (controlledValue !== undefined) {
49
+ setUncontrolledValue(controlledValue);
50
+ }
51
+ }, [controlledValue]);
52
+ useEffect(() => {
53
+ const currentOptionValues = optionValuesRef.current;
54
+ setUncontrolledValue(prev => {
55
+ if (!prev)
56
+ return currentOptionValues[0];
57
+ if (!currentOptionValues.includes(prev))
58
+ return currentOptionValues[0];
59
+ return prev;
60
+ });
61
+ }, [optionValuesKey]);
62
+ const currentValue = controlledValue !== undefined ? controlledValue : uncontrolledValue;
63
+ const indicatorX = useSharedValue(0);
64
+ const indicatorWidth = useSharedValue(0);
65
+ const indicatorY = useSharedValue(0);
66
+ const indicatorHeight = useSharedValue(0);
67
+ const [hasIndicator, setHasIndicator] = useState(false);
68
+ const layoutsRef = useRef(new Map());
69
+ const prevValueRef = useRef(undefined);
70
+ const initialisedRef = useRef(false);
71
+ const select = useCallback((nextValue) => {
72
+ if (disabled)
73
+ return;
74
+ if (controlledValue === undefined) {
75
+ setUncontrolledValue(nextValue);
76
+ }
77
+ onValueChange?.(nextValue);
78
+ }, [controlledValue, disabled, onValueChange]);
79
+ const registerOptionLayout = useCallback((value, layout) => {
80
+ layoutsRef.current.set(value, layout);
81
+ const activeValue = controlledValue !== undefined ? controlledValue : uncontrolledValue;
82
+ if (!activeValue || activeValue !== value)
83
+ return;
84
+ if (!initialisedRef.current) {
85
+ indicatorX.value = Math.max(0, layout.x - indicatorPositionOffset);
86
+ indicatorWidth.value = layout.width;
87
+ indicatorY.value = Math.max(0, layout.y - indicatorPositionOffset);
88
+ indicatorHeight.value = layout.height;
89
+ prevValueRef.current = activeValue;
90
+ initialisedRef.current = true;
91
+ setHasIndicator(true);
92
+ return;
93
+ }
94
+ if (prevValueRef.current === activeValue)
95
+ return;
96
+ const config = {
97
+ delay: 200,
98
+ duration: isReducedMotion ? 0 : 220,
99
+ easing: Easing.out(Easing.cubic),
100
+ };
101
+ indicatorX.value = withTiming(Math.max(0, layout.x - indicatorPositionOffset), config);
102
+ indicatorWidth.value = withTiming(layout.width, config);
103
+ indicatorY.value = withTiming(Math.max(0, layout.y - indicatorPositionOffset), config);
104
+ indicatorHeight.value = withTiming(layout.height, config);
105
+ prevValueRef.current = activeValue;
106
+ }, [
107
+ controlledValue,
108
+ indicatorHeight,
109
+ indicatorWidth,
110
+ indicatorX,
111
+ indicatorY,
112
+ indicatorPositionOffset,
113
+ isReducedMotion,
114
+ uncontrolledValue,
115
+ ]);
116
+ useEffect(() => {
117
+ if (!currentValue || !initialisedRef.current)
118
+ return;
119
+ if (prevValueRef.current === undefined || prevValueRef.current === currentValue)
120
+ return;
121
+ const layout = layoutsRef.current.get(currentValue);
122
+ if (!layout)
123
+ return;
124
+ const config = {
125
+ duration: isReducedMotion ? 0 : 220,
126
+ easing: Easing.out(Easing.cubic),
127
+ };
128
+ indicatorX.value = withTiming(Math.max(0, layout.x - indicatorPositionOffset), config);
129
+ indicatorWidth.value = withTiming(layout.width, config);
130
+ indicatorY.value = withTiming(Math.max(0, layout.y - indicatorPositionOffset), config);
131
+ indicatorHeight.value = withTiming(layout.height, config);
132
+ prevValueRef.current = currentValue;
133
+ }, [
134
+ currentValue,
135
+ indicatorHeight,
136
+ indicatorWidth,
137
+ indicatorX,
138
+ indicatorY,
139
+ indicatorPositionOffset,
140
+ isReducedMotion,
141
+ optionValuesKey,
142
+ ]);
143
+ const indicatorStyle = useAnimatedStyle(() => ({
144
+ transform: [{ translateX: indicatorX.value }, { translateY: indicatorY.value }],
145
+ width: indicatorWidth.value,
146
+ height: indicatorHeight.value,
147
+ }));
148
+ styles.useVariants({ disabled, size });
149
+ const contextValue = useMemo(() => ({
150
+ value: currentValue,
151
+ select,
152
+ disabled,
153
+ size,
154
+ registerOptionLayout,
155
+ }), [currentValue, select, disabled, size, registerOptionLayout]);
156
+ return (_jsx(SegmentedControlContext.Provider, { value: contextValue, children: _jsxs(View, { accessibilityRole: "radiogroup", accessibilityState: { disabled }, style: [styles.container, computedStyles, style], ...remainingProps, children: [hasIndicator ? (_jsx(Indicator, { pointerEvents: "none", style: [styles.indicator, indicatorStyle] })) : null, children] }) }));
157
+ };
158
+ SegmentedControl.displayName = 'SegmentedControl';
159
+ const styles = StyleSheet.create(theme => ({
160
+ container: {
161
+ flexDirection: 'row',
162
+ alignItems: 'center',
163
+ alignSelf: 'flex-start',
164
+ gap: theme.components.segmentedControl.group.gap,
165
+ height: theme.components.segmentedControl.group.height,
166
+ borderRadius: theme.components.segmentedControl.group.borderRadius,
167
+ borderWidth: theme.components.segmentedControl.group.borderWidth,
168
+ backgroundColor: theme.color.surface.neutral.subtle,
169
+ borderColor: theme.color.border.strong,
170
+ variants: {
171
+ size: {
172
+ sm: {
173
+ height: 32,
174
+ padding: 2,
175
+ },
176
+ md: {
177
+ height: theme.components.segmentedControl.group.height,
178
+ padding: 2,
179
+ },
180
+ },
181
+ disabled: {
182
+ true: {
183
+ opacity: theme.opacity.disabled,
184
+ },
185
+ },
186
+ },
187
+ },
188
+ indicator: {
189
+ position: 'absolute',
190
+ left: 0,
191
+ top: 0,
192
+ borderRadius: theme.components.segmentedControl.borderRadius,
193
+ backgroundColor: theme.color.interactive.brand.surface.strong.default,
194
+ },
195
+ }));
196
+ export default SegmentedControl;
@@ -0,0 +1,18 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ViewProps } from 'react-native';
3
+ import type { FlexLayoutProps } from '../../types';
4
+ export interface SegmentedControlProps extends ViewProps, FlexLayoutProps {
5
+ /** Controlled selected option value. */
6
+ value?: string;
7
+ /** Initial selected option value for uncontrolled mode. */
8
+ defaultValue?: string;
9
+ /** Called when selected option changes. */
10
+ onValueChange?: (value: string) => void;
11
+ /** Size variant. */
12
+ size?: 'sm' | 'md';
13
+ /** Disables all options in the control. */
14
+ disabled?: boolean;
15
+ /** SegmentedControlOption children. */
16
+ children: ReactNode;
17
+ }
18
+ export default SegmentedControlProps;
@@ -0,0 +1,18 @@
1
+ import type SegmentedControlOptionProps from './SegmentedControlOption.props';
2
+ declare const SegmentedControlOption: import("react").ForwardRefExoticComponent<SegmentedControlOptionProps & {
3
+ states?: {
4
+ active?: boolean;
5
+ disabled?: boolean;
6
+ };
7
+ } & Omit<import("react-native").PressableProps, "children"> & {
8
+ tabIndex?: 0 | -1 | undefined;
9
+ } & {
10
+ children?: import("react").ReactNode | (({ hovered, pressed, focused, focusVisible, disabled, }: {
11
+ hovered?: boolean | undefined;
12
+ pressed?: boolean | undefined;
13
+ focused?: boolean | undefined;
14
+ focusVisible?: boolean | undefined;
15
+ disabled?: boolean | undefined;
16
+ }) => import("react").ReactNode);
17
+ } & import("react").RefAttributes<unknown>>;
18
+ export default SegmentedControlOption;