@utilitywarehouse/hearth-react-native 0.34.3 → 0.34.5

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # @utilitywarehouse/hearth-react-native
2
2
 
3
+ ## 0.34.5
4
+
5
+ ### Patch Changes
6
+
7
+ - [#1384](https://github.com/utilitywarehouse/hearth/pull/1384) [`be839e2`](https://github.com/utilitywarehouse/hearth/commit/be839e2ca46f079a0fe867f976044f4f31cca551) Thanks [@jordmccord](https://github.com/jordmccord)! - 🌟 [FEATURE]: `NavModal` and `Modal` `description` prop accepts JSX
8
+
9
+ `description` now accepts `ReactNode` in addition to a string, so you can pass
10
+ custom content such as links or styled text alongside the default text
11
+ styling.
12
+
13
+ **Components affected**:
14
+
15
+ - `NavModal`
16
+ - `Modal`
17
+
18
+ **Developer changes**:
19
+
20
+ No changes required for existing usage. To pass custom content, provide JSX
21
+ instead of a string:
22
+
23
+ ```tsx
24
+ <Modal
25
+ heading="Update available"
26
+ description={
27
+ <BodyText>
28
+ Read the <InlineLink onPress={() => {}}>release notes</InlineLink> before updating.
29
+ </BodyText>
30
+ }
31
+ />
32
+ ```
33
+
34
+ ## 0.34.4
35
+
36
+ ### Patch Changes
37
+
38
+ - [#1375](https://github.com/utilitywarehouse/hearth/pull/1375) [`549739e`](https://github.com/utilitywarehouse/hearth/commit/549739eae90e467dbe2311dbc60cfd35d13f49c4) Thanks [@declanelcocks](https://github.com/declanelcocks)! - 🐛 [FIX]: `Modal` image container children pushed to bottom, and missing gap between image and heading
39
+
40
+ `imageContainer` had `flex: 1` applied, which caused `children` to be pushed to the bottom of the container. Additionally, the gap between the `image` and the `heading` was missing.
41
+
3
42
  ## 0.34.3
4
43
 
5
44
  ### Patch Changes
package/CLAUDE.md ADDED
@@ -0,0 +1,109 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ This file covers `packages/react-native` — `@utilitywarehouse/hearth-react-native`, the React Native
6
+ component library. See the [repo-root CLAUDE.md](../../CLAUDE.md) for monorepo-wide setup and commands.
7
+
8
+ ## Commands
9
+
10
+ Run from this directory (`packages/react-native`), or via the root scripts shown for Turbo-filtered variants.
11
+
12
+ ```sh
13
+ pnpm dev # Storybook (React Native Web) on :6006 — primary dev loop for this package
14
+ pnpm dev:docs # Storybook docs-only mode on :6002
15
+
16
+ pnpm build # tsc — compiles src/ to build/
17
+ pnpm watch # tsc --watch
18
+
19
+ pnpm test # vitest run --config vitest.unit.config.ts — unit tests (src/**/*.test.{ts,tsx})
20
+ pnpm test:storybook # vitest run --project storybook — runs stories as browser tests via Playwright
21
+
22
+ pnpm lint # eslint .
23
+ pnpm lint:fix # eslint --fix .
24
+
25
+ pnpm copyTokens # vendors CSS tokens from packages/tokens into src/tokens — run after changing hearth-tokens
26
+ pnpm generateColours # regenerates legacy colour token files
27
+
28
+ pnpm figma:create # scaffold a Code Connect entry
29
+ pnpm figma:publish # publish Code Connect mappings
30
+ ```
31
+
32
+ Run a single unit test file: `pnpm test src/hooks/useFormFieldAccessibility.test.tsx`.
33
+
34
+ Root-level equivalents (from repo root):
35
+ ```sh
36
+ pnpm dev:react-native # turbo run @utilitywarehouse/hearth-react-native#dev
37
+ pnpm test:react-native # unit tests + storybook tests for this package
38
+ pnpm build:react-native # turbo run build:storybook, filtered to this package
39
+ ```
40
+
41
+ ## Architecture
42
+
43
+ ### Styling: Unistyles, not StyleSheet-from-react-native
44
+
45
+ Every component is styled with `react-native-unistyles`'s `StyleSheet.create(theme => ({...}))`, re-exported
46
+ from `src/core`. Styles are defined as **variants** (e.g. `size`, `colorScheme`, `variant`, `disabled`) plus
47
+ `compoundVariants` for combinations that need distinct treatment, and applied at render time via
48
+ `styles.useVariants({...})`. See [`Button/ButtonRoot.tsx`](src/components/Button/ButtonRoot.tsx) for the
49
+ canonical example — colour/variant/state combinations are resolved entirely through compound variants rather
50
+ than conditional logic in the component body. `_web` blocks inside a style object hold web-only pseudo-class
51
+ styles (`_hover`, `_focus-visible`, `_active`) since Unistyles targets both native and `react-native-web`.
52
+
53
+ Unistyles is configured once in `src/core/index.ts` (`StyleSheet.configure({ breakpoints, themes, settings })`),
54
+ which is why components never configure themes themselves — they just consume `theme` inside `StyleSheet.create`.
55
+
56
+ ### Token pipeline: tokens package → legacy + generated → theme
57
+
58
+ - `src/tokens/` is **generated, not hand-edited** ("Do not edit directly" — see file headers). It's populated
59
+ by `pnpm copyTokens` from `@utilitywarehouse/hearth-tokens` (see root CLAUDE.md: tokens are vendored, not a
60
+ runtime dependency).
61
+ - `src/legacyTokens/` holds older colour tokens (`colors`, `colorsCommon`, `colorsDark`) still consumed by
62
+ `src/core/themes.ts` alongside the generated tokens — check both when tracing a colour value.
63
+ - `src/core/themes.ts` assembles the final `light`/`dark` theme objects consumed via `theme.*` inside
64
+ components, merging `tokens/*` (space, color, typography, shadow, border, `components.*` for
65
+ per-component tokens) with responsive breakpoint-aware values (`base`/`md`/`lg` per breakpoint).
66
+ - Component-specific tokens live under `theme.components.<componentName>` (e.g. `theme.components.button.md.paddingVertical`)
67
+ — check `src/tokens/components/{light,dark}` before hardcoding a dimension in a component.
68
+
69
+ ### Component folder convention
70
+
71
+ Each component under `src/components/<Component>/` follows a fixed file set (see the
72
+ `react-native-component-addition` skill for full detail when adding a new one):
73
+
74
+ ```
75
+ <Component>.tsx / <Component>Root.tsx / <Component>Xyz.tsx # implementation, split by subcomponent
76
+ <Component>.props.ts # public prop types
77
+ <Component>.context.ts # React context for compound components (e.g. ButtonContext shares state with ButtonIcon/ButtonText)
78
+ <Component>.stories.tsx # Storybook stories
79
+ <Component>.docs.mdx # Storybook docs page
80
+ <Component>.figma.tsx # Figma Code Connect mapping (excluded from eslint and turbo build inputs)
81
+ index.ts # public exports for the folder
82
+ ```
83
+
84
+ Compound components (Button, Accordion, Card, etc.) share state via a `<Component>Context` rather than prop
85
+ drilling — check for a `.context.ts` file before assuming a subcomponent is standalone.
86
+
87
+ `src/index.ts` re-exports only `core`, `hooks`, and `components` — anything not re-exported there (e.g.
88
+ internal `utils/`) is not part of the public API.
89
+
90
+ ### Storybook / testing split
91
+
92
+ There are two distinct test configs, and they check different things:
93
+ - `vitest.unit.config.ts` — plain unit tests (`src/**/*.test.ts(x)`), Node environment, e.g.
94
+ [`useFormFieldAccessibility.test.tsx`](src/hooks/useFormFieldAccessibility.test.tsx).
95
+ - `vitest.config.js` — runs `*.stories.tsx` themselves as browser tests via `@storybook/addon-vitest` +
96
+ Playwright/Chromium, driven by `.storybook/` config. This is how story-level interaction tests are verified,
97
+ not a separate manual QA step.
98
+
99
+ Storybook itself targets `@storybook/react-native-web-vite`, so stories run against `react-native-web`, not a
100
+ native simulator — this is also why `_web`-scoped Unistyles blocks matter for how components look in Storybook.
101
+
102
+ ### Peer dependency surface
103
+
104
+ This package is built on and requires consumers to install `react-native-unistyles`, `react-native-svg`,
105
+ `react-native-reanimated` (+ `react-native-worklets`), `react-native-gesture-handler`,
106
+ `react-native-safe-area-context`, `@gorhom/bottom-sheet`, and `@utilitywarehouse/hearth-react-native-icons`
107
+ (see `docs/introduction.mdx` for consumer-side setup — Babel plugin, font linking, Jest mocks). When changing a
108
+ component's animation or gesture behaviour, assume these are the only native modules available; don't add a new
109
+ native dependency without checking the peerDependencies list in `package.json`.
@@ -70,6 +70,8 @@ const Modal = ({ ref, children, heading, description, showCloseButton = true, pr
70
70
  const hasSecondaryButton = !!(onPressSecondaryButton && secondaryButtonText);
71
71
  const hasFooter = !!footer || hasPrimaryButton || hasSecondaryButton;
72
72
  const shouldShowFooter = !loading && hasFooter;
73
+ const descriptionIsText = typeof description === 'string' || typeof description === 'number';
74
+ const hasDescription = description !== undefined && description !== null;
73
75
  styles.useVariants({
74
76
  loading,
75
77
  noButtons: !shouldShowFooter,
@@ -88,7 +90,7 @@ const Modal = ({ ref, children, heading, description, showCloseButton = true, pr
88
90
  secondaryButtonProps,
89
91
  secondaryButtonText,
90
92
  ]);
91
- const content = (_jsx(_Fragment, { children: loading ? (_jsxs(View, { style: styles.loadingContainer, accessible: Platform.OS === 'android' ? true : undefined, accessibilityLabel: Platform.OS === 'android' ? (loadingHeading ?? 'Loading') : undefined, accessibilityHint: Platform.OS === 'android' && loadingDescription ? loadingDescription : undefined, screenReaderFocusable: true, ref: viewRef, children: [_jsx(Spinner, { size: "lg" }), _jsx(Heading, { size: "lg", textAlign: "center", children: loadingHeading }), loadingDescription ? _jsx(BodyText, { textAlign: "center", children: loadingDescription }) : null] })) : (_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, children: heading })) : null, description && !image ? _jsx(BodyText, { accessible: true, children: description }) : null] }), showCloseButton ? (_jsx(UnstyledIconButton, { icon: CloseMediumIcon, onPress: handleCloseButtonPress, accessibilityLabel: "Close modal", ...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, children: heading })) : null, description ? (_jsx(BodyText, { textAlign: "center", accessible: true, children: description })) : null] })] })) : null, children, !stickyFooter && shouldShowFooter ? (_jsx(View, { style: footerStyle, children: footerContent })) : null] })) }));
93
+ const content = (_jsx(_Fragment, { children: loading ? (_jsxs(View, { style: styles.loadingContainer, accessible: Platform.OS === 'android' ? true : undefined, accessibilityLabel: Platform.OS === 'android' ? (loadingHeading ?? 'Loading') : undefined, accessibilityHint: Platform.OS === 'android' && loadingDescription ? loadingDescription : undefined, screenReaderFocusable: true, ref: viewRef, children: [_jsx(Spinner, { size: "lg" }), _jsx(Heading, { size: "lg", textAlign: "center", children: loadingHeading }), loadingDescription ? _jsx(BodyText, { textAlign: "center", children: loadingDescription }) : null] })) : (_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, children: heading })) : null, hasDescription && !image ? (descriptionIsText ? (_jsx(BodyText, { accessible: true, children: description })) : (description)) : null] }), showCloseButton ? (_jsx(UnstyledIconButton, { icon: CloseMediumIcon, onPress: handleCloseButtonPress, accessibilityLabel: "Close modal", ...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, children: heading })) : null, hasDescription ? (descriptionIsText ? (_jsx(BodyText, { textAlign: "center", accessible: true, children: description })) : (description)) : null] })] })) : null, children, !stickyFooter && shouldShowFooter ? (_jsx(View, { style: footerStyle, children: footerContent })) : null] })) }));
92
94
  const renderFooter = useCallback((bottomSheetFooterProps) => (_jsx(BottomSheetFooter, { ...bottomSheetFooterProps, children: _jsx(View, { onLayout: handleStickyFooterLayout, style: [styles.footerWrap, footerStyle], children: footerContent }) })), [footerContent, footerStyle, handleStickyFooterLayout]);
93
95
  return (_jsxs(_Fragment, { children: [stickyFooter && shouldShowFooter && stickyFooterHeight === 0 ? (_jsx(View, { accessible: false, importantForAccessibility: "no-hide-descendants", pointerEvents: "none", style: styles.footerMeasurementContainer, children: _jsx(View, { onLayout: handleStickyFooterLayout, style: [styles.footerWrap, footerStyle], children: footerContent }) })) : null, _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 && shouldShowFooter ? renderFooter : undefined, ...props, onChange: handleChange, children: [loading ? _jsx(View, { style: styles.loadingTop }) : null, _jsx(BottomSheetScrollView, { contentContainerStyle: [
94
96
  styles.scrollView,
@@ -171,7 +173,7 @@ const styles = StyleSheet.create((theme, rt) => ({
171
173
  },
172
174
  imageContainer: {
173
175
  alignItems: 'center',
174
- flex: 1,
176
+ gap: theme.components.modal.content.gap,
175
177
  },
176
178
  textContent: {
177
179
  gap: theme.components.modal.content.gap,
@@ -9,7 +9,7 @@ export interface ModalCommonBaseProps {
9
9
  heading?: string;
10
10
  loadingHeading?: string;
11
11
  loadingDescription?: string;
12
- description?: string;
12
+ description?: ReactNode;
13
13
  stickyFooter?: boolean;
14
14
  children?: ViewProps['children'];
15
15
  onPressCloseButton?: () => void;
@@ -59,8 +59,10 @@ const Modal = ({ ref, children, heading, description, showCloseButton = true, pr
59
59
  const hasPrimaryButton = !!(onPressPrimaryButton && primaryButtonText);
60
60
  const hasSecondaryButton = !!(onPressSecondaryButton && secondaryButtonText);
61
61
  const hasFooter = !!footer || hasPrimaryButton || hasSecondaryButton;
62
+ const descriptionIsText = typeof description === 'string' || typeof description === 'number';
63
+ const hasDescription = description !== undefined && description !== null;
62
64
  const footerContent = footer ?? (_jsxs(View, { style: styles.footer, children: [hasPrimaryButton ? (_jsx(Button, { onPress: handlePrimaryButtonPress, text: primaryButtonText, ...primaryButtonProps, variant: primaryButtonProps?.variant ?? 'solid', colorScheme: primaryButtonProps?.colorScheme ?? 'highlight' })) : null, hasSecondaryButton ? (_jsx(Button, { onPress: handleSecondaryButtonPress, text: secondaryButtonText, ...secondaryButtonProps, variant: secondaryButtonProps?.variant ?? 'outline', colorScheme: secondaryButtonProps?.colorScheme ?? 'functional' })) : null] }));
63
- 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" }), _jsx(Heading, { size: "lg", textAlign: "center", 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, children: heading })) : null, description && !image ? _jsx(BodyText, { accessible: true, children: description }) : null] }), showCloseButton ? (_jsx(UnstyledIconButton, { icon: CloseMediumIcon, onPress: handleCloseButtonPress, accessibilityLabel: "Close modal", ...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, children: heading })) : null, description ? (_jsx(BodyText, { textAlign: "center", accessible: true, children: description })) : null] })] })) : null, children, hasFooter ? _jsx(View, { style: footerStyle, children: footerContent }) : null] })) }));
65
+ 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" }), _jsx(Heading, { size: "lg", textAlign: "center", 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, children: heading })) : null, hasDescription && !image ? (descriptionIsText ? (_jsx(BodyText, { accessible: true, children: description })) : (description)) : null] }), showCloseButton ? (_jsx(UnstyledIconButton, { icon: CloseMediumIcon, onPress: handleCloseButtonPress, accessibilityLabel: "Close modal", ...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, children: heading })) : null, hasDescription ? (descriptionIsText ? (_jsx(BodyText, { textAlign: "center", accessible: true, children: description })) : (description)) : null] })] })) : null, children, hasFooter ? _jsx(View, { style: footerStyle, children: footerContent }) : null] })) }));
64
66
  return (_jsx(BottomSheetModal, { ref: bottomSheetModalRef, enableDynamicSizing: true, snapPoints: image || fullscreen ? ['90%'] : props.snapPoints, showHandle: typeof loading !== 'undefined' && loading ? false : props.showHandle, accessible: false, ...props, onChange: handleChange, children: _jsx(BottomSheetScrollView, { contentContainerStyle: styles.container, ref: scrollViewRef, children: content }) }));
65
67
  };
66
68
  const styles = StyleSheet.create(theme => ({
@@ -63,6 +63,8 @@ const NavModal = ({ ref, children, heading, description, showCloseButton = true,
63
63
  const hasPrimaryButton = !!(onPressPrimaryButton && primaryButtonText);
64
64
  const hasSecondaryButton = !!(onPressSecondaryButton && secondaryButtonText);
65
65
  const hasFooter = !!footer || hasPrimaryButton || hasSecondaryButton;
66
+ const descriptionIsText = typeof description === 'string' || typeof description === 'number';
67
+ const hasDescription = description !== undefined && description !== null;
66
68
  styles.useVariants({
67
69
  loading,
68
70
  background: isBrandBackground ? 'brand' : 'primary',
@@ -83,7 +85,7 @@ const NavModal = ({ ref, children, heading, description, showCloseButton = true,
83
85
  secondaryButtonProps,
84
86
  secondaryButtonText,
85
87
  ]);
86
- const content = (_jsx(_Fragment, { children: loading ? (_jsxs(View, { style: styles.loadingContainer, accessible: Platform.OS === 'android' ? true : undefined, accessibilityLabel: Platform.OS === 'android' ? (loadingHeading ?? 'Loading') : undefined, accessibilityHint: Platform.OS === 'android' && loadingDescription ? loadingDescription : undefined, screenReaderFocusable: true, children: [_jsx(Spinner, { size: "lg", color: isBrandBackground ? theme.color.icon.inverted : undefined }), _jsx(Heading, { size: "lg", textAlign: "center", inverted: isBrandBackground, children: loadingHeading }), loadingDescription ? (_jsx(BodyText, { size: "md", textAlign: "center", inverted: isBrandBackground, children: loadingDescription })) : null] })) : (_jsxs(View, { style: styles.container, accessible: Platform.OS === 'android' ? true : undefined, accessibilityLabel: Platform.OS === 'android' ? 'Modal content' : undefined, screenReaderFocusable: true, children: [_jsxs(View, { style: styles.header, children: [_jsxs(View, { style: styles.headerTextContent, children: [heading && !image ? (_jsx(Heading, { size: "lg", accessible: true, inverted: isBrandBackground, children: heading })) : null, description && !image ? (_jsx(BodyText, { accessible: true, inverted: isBrandBackground, children: description })) : null] }), showCloseButton ? (_jsx(UnstyledIconButton, { icon: CloseMediumIcon, onPress: handleCloseButtonPress, accessibilityLabel: "Close modal", inverted: isBrandBackground, ...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, children: heading })) : null, description ? (_jsx(BodyText, { textAlign: "center", accessible: true, inverted: isBrandBackground, children: description })) : null] })] })) : null, scrollable ? (_jsxs(ScrollView, { style: {
88
+ const content = (_jsx(_Fragment, { children: loading ? (_jsxs(View, { style: styles.loadingContainer, accessible: Platform.OS === 'android' ? true : undefined, accessibilityLabel: Platform.OS === 'android' ? (loadingHeading ?? 'Loading') : undefined, accessibilityHint: Platform.OS === 'android' && loadingDescription ? loadingDescription : undefined, screenReaderFocusable: true, children: [_jsx(Spinner, { size: "lg", color: isBrandBackground ? theme.color.icon.inverted : undefined }), _jsx(Heading, { size: "lg", textAlign: "center", inverted: isBrandBackground, children: loadingHeading }), loadingDescription ? (_jsx(BodyText, { size: "md", textAlign: "center", inverted: isBrandBackground, children: loadingDescription })) : null] })) : (_jsxs(View, { style: styles.container, accessible: Platform.OS === 'android' ? true : undefined, accessibilityLabel: Platform.OS === 'android' ? 'Modal content' : undefined, screenReaderFocusable: true, children: [_jsxs(View, { style: styles.header, children: [_jsxs(View, { style: styles.headerTextContent, children: [heading && !image ? (_jsx(Heading, { size: "lg", accessible: true, inverted: isBrandBackground, children: heading })) : null, hasDescription && !image ? (descriptionIsText ? (_jsx(BodyText, { accessible: true, inverted: isBrandBackground, children: description })) : (description)) : null] }), showCloseButton ? (_jsx(UnstyledIconButton, { icon: CloseMediumIcon, onPress: handleCloseButtonPress, accessibilityLabel: "Close modal", inverted: isBrandBackground, ...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, children: heading })) : null, hasDescription ? (descriptionIsText ? (_jsx(BodyText, { textAlign: "center", accessible: true, inverted: isBrandBackground, children: description })) : (description)) : null] })] })) : null, scrollable ? (_jsxs(ScrollView, { style: {
87
89
  flex: stickyFooter ? 1 : 0,
88
90
  marginHorizontal: -4,
89
91
  }, contentContainerStyle: { paddingHorizontal: 4 }, ...scrollViewProps, children: [children, !stickyFooter && hasFooter ? (_jsx(View, { style: [styles.inNavModalFooterContainer, footerStyle], children: footerContent })) : null] })) : (_jsxs(View, { style: {
@@ -9,6 +9,45 @@ import { BackToTopButton, NextPrevPage } from './components';
9
9
  The changelog for the Hearth React Native library. Here you can find all the changes, improvements, and bug fixes for each version.
10
10
 
11
11
 
12
+ ## 0.34.5
13
+
14
+ ### Patch Changes
15
+
16
+ - [#1384](https://github.com/utilitywarehouse/hearth/pull/1384) [`be839e2`](https://github.com/utilitywarehouse/hearth/commit/be839e2ca46f079a0fe867f976044f4f31cca551) Thanks [@jordmccord](https://github.com/jordmccord)! - 🌟 [FEATURE]: `NavModal` and `Modal` `description` prop accepts JSX
17
+
18
+ `description` now accepts `ReactNode` in addition to a string, so you can pass
19
+ custom content such as links or styled text alongside the default text
20
+ styling.
21
+
22
+ **Components affected**:
23
+
24
+ - `NavModal`
25
+ - `Modal`
26
+
27
+ **Developer changes**:
28
+
29
+ No changes required for existing usage. To pass custom content, provide JSX
30
+ instead of a string:
31
+
32
+ ```tsx
33
+ <Modal
34
+ heading="Update available"
35
+ description={
36
+ <BodyText>
37
+ Read the <InlineLink onPress={() => {}}>release notes</InlineLink> before updating.
38
+ </BodyText>
39
+ }
40
+ />
41
+ ```
42
+
43
+ ## 0.34.4
44
+
45
+ ### Patch Changes
46
+
47
+ - [#1375](https://github.com/utilitywarehouse/hearth/pull/1375) [`549739e`](https://github.com/utilitywarehouse/hearth/commit/549739eae90e467dbe2311dbc60cfd35d13f49c4) Thanks [@declanelcocks](https://github.com/declanelcocks)! - 🐛 [FIX]: `Modal` image container children pushed to bottom, and missing gap between image and heading
48
+
49
+ `imageContainer` had `flex: 1` applied, which caused `children` to be pushed to the bottom of the container. Additionally, the gap between the `image` and the `heading` was missing.
50
+
12
51
  ## 0.34.3
13
52
 
14
53
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@utilitywarehouse/hearth-react-native",
3
- "version": "0.34.3",
3
+ "version": "0.34.5",
4
4
  "description": "Utility Warehouse React Native UI library",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -60,9 +60,9 @@
60
60
  "vite-plugin-svgr": "^4.5.0",
61
61
  "vitest": "^4.1.7",
62
62
  "@utilitywarehouse/hearth-storybook-utils": "0.0.0",
63
+ "@utilitywarehouse/hearth-react-icons": "^0.8.6",
64
+ "@utilitywarehouse/hearth-react-native-icons": "^0.8.5",
63
65
  "@utilitywarehouse/hearth-fonts": "^0.1.1",
64
- "@utilitywarehouse/hearth-react-icons": "^0.8.5",
65
- "@utilitywarehouse/hearth-react-native-icons": "^0.8.4",
66
66
  "@utilitywarehouse/hearth-svg-assets": "^0.6.4",
67
67
  "@utilitywarehouse/hearth-tokens": "^0.4.1"
68
68
  },
@@ -1,5 +1,15 @@
1
1
  import { Canvas, Controls, Meta, Story } from '@storybook/addon-docs/blocks';
2
- import { BodyText, BottomSheetModal, Box, Button, Center, Flex, Heading, Modal } from '../../';
2
+ import {
3
+ BodyText,
4
+ BottomSheetModal,
5
+ Box,
6
+ Button,
7
+ Center,
8
+ Flex,
9
+ Heading,
10
+ InlineLink,
11
+ Modal,
12
+ } from '../../';
3
13
  import StorybookLink from '../../../../../shared/storybook/StorybookLink';
4
14
  import { BackToTopButton, UsageWrap, ViewFigmaButton } from '../../../docs/components';
5
15
  import * as Stories from './Modal.stories';
@@ -31,6 +41,7 @@ If you need a modal layout inside a React Navigation modal screen, use <Storyboo
31
41
  - [Modal with Image](#modal-with-image)
32
42
  - [Fullscreen Modal](#fullscreen-modal)
33
43
  - [Modal with Custom Content](#modal-with-custom-content)
44
+ - [JSX Description](#jsx-description)
34
45
  - [Sticky Custom Footer](#sticky-custom-footer)
35
46
  - [Loading State](#loading-state)
36
47
  - [Without Close Button](#without-close-button)
@@ -93,30 +104,30 @@ const MyComponent = () => {
93
104
 
94
105
  The Modal component extends the `BottomSheetModal` component and accepts all of its props, plus the following additional props:
95
106
 
96
- | Property | Type | Description | Default |
97
- | ----------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -------------- |
98
- | `heading` | `string` | The heading text displayed at the top of the modal | - |
99
- | `description` | `string` | The description text displayed below the heading | - |
100
- | `showCloseButton` | `boolean` | Whether to show the close button in the top-right corner | `true` |
101
- | `primaryButtonText` | `string` | Text for the primary action button | - |
102
- | `secondaryButtonText` | `string` | Text for the secondary action button | - |
103
- | `onPressPrimaryButton` | `() => void` | Callback function called when the primary button is pressed | - |
104
- | `onPressSecondaryButton` | `() => void` | Callback function called when the secondary button is pressed | - |
105
- | `onPressCloseButton` | `() => void` | Callback function called when the close button is pressed | - |
106
- | `closeOnPrimaryButtonPress` | `boolean` | Whether to automatically close the modal when the primary button is pressed | `true` |
107
- | `closeOnSecondaryButtonPress` | `boolean` | Whether to automatically close the modal when the secondary button is pressed | `true` |
108
- | `onChange` | `(index: number, position: number, `<br />` type: number) => void` | Callback function called when the modal's position changes \* | - |
109
- | `loading` | `boolean` | Whether to show a loading state with spinner | `false` |
110
- | `loadingHeading` | `string` | The heading text to be displayed when loading is true. If not provided, the regular heading will be shown. | `'Loading...'` |
111
- | `loadingDescription` | `string` | The description text to be displayed when loading is true. If not provided, the regular description will be shown. | - |
112
- | `image` | `ImageProps` | Image to display in the modal (shows as centered content with text below) | - |
113
- | `children` | `ReactNode` | Custom content to display in the modal body | - |
114
- | `primaryButtonProps` | `Omit<ButtonWithoutChildrenProps, 'children'>` | Additional props to pass to the primary button (colorScheme defaults to 'highlight', variant to 'solid') | - |
115
- | `secondaryButtonProps` | `Omit<ButtonWithoutChildrenProps, 'children'>` | Additional props to pass to the secondary button (colorScheme defaults to 'functional', variant to 'outline') | - |
116
- | `footer` | `ReactNode` | Custom footer content that replaces the built-in action buttons | - |
117
- | `footerStyle` | `StyleProp<ViewStyle>` | Styles applied to the footer container, useful for sticky footer shadows or custom spacing | - |
118
- | `closeButtonProps` | `Omit<UnstyledIconButtonProps, 'children'>` | Additional props to pass to the close button | - |
119
- | `fullscreen` | `boolean` | Whether the modal should take up the full screen height | `false` |
107
+ | Property | Type | Description | Default |
108
+ | ----------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | -------------- |
109
+ | `heading` | `string` | The heading text displayed at the top of the modal | - |
110
+ | `description` | `ReactNode` | The description content displayed below the heading. Pass a string for the default styling, or JSX for custom content. | - |
111
+ | `showCloseButton` | `boolean` | Whether to show the close button in the top-right corner | `true` |
112
+ | `primaryButtonText` | `string` | Text for the primary action button | - |
113
+ | `secondaryButtonText` | `string` | Text for the secondary action button | - |
114
+ | `onPressPrimaryButton` | `() => void` | Callback function called when the primary button is pressed | - |
115
+ | `onPressSecondaryButton` | `() => void` | Callback function called when the secondary button is pressed | - |
116
+ | `onPressCloseButton` | `() => void` | Callback function called when the close button is pressed | - |
117
+ | `closeOnPrimaryButtonPress` | `boolean` | Whether to automatically close the modal when the primary button is pressed | `true` |
118
+ | `closeOnSecondaryButtonPress` | `boolean` | Whether to automatically close the modal when the secondary button is pressed | `true` |
119
+ | `onChange` | `(index: number, position: number, `<br />` type: number) => void` | Callback function called when the modal's position changes \* | - |
120
+ | `loading` | `boolean` | Whether to show a loading state with spinner | `false` |
121
+ | `loadingHeading` | `string` | The heading text to be displayed when loading is true. If not provided, the regular heading will be shown. | `'Loading...'` |
122
+ | `loadingDescription` | `string` | The description text to be displayed when loading is true. If not provided, the regular description will be shown. | - |
123
+ | `image` | `ImageProps` | Image to display in the modal (shows as centered content with text below) | - |
124
+ | `children` | `ReactNode` | Custom content to display in the modal body | - |
125
+ | `primaryButtonProps` | `Omit<ButtonWithoutChildrenProps, 'children'>` | Additional props to pass to the primary button (colorScheme defaults to 'highlight', variant to 'solid') | - |
126
+ | `secondaryButtonProps` | `Omit<ButtonWithoutChildrenProps, 'children'>` | Additional props to pass to the secondary button (colorScheme defaults to 'functional', variant to 'outline') | - |
127
+ | `footer` | `ReactNode` | Custom footer content that replaces the built-in action buttons | - |
128
+ | `footerStyle` | `StyleProp<ViewStyle>` | Styles applied to the footer container, useful for sticky footer shadows or custom spacing | - |
129
+ | `closeButtonProps` | `Omit<UnstyledIconButtonProps, 'children'>` | Additional props to pass to the close button | - |
130
+ | `fullscreen` | `boolean` | Whether the modal should take up the full screen height | `false` |
120
131
 
121
132
  When `footer` is provided, the primary and secondary button props are not available. Build your footer actions directly inside the custom footer content instead.
122
133
 
@@ -381,6 +392,22 @@ const CustomContentModal = () => {
381
392
  };
382
393
  ```
383
394
 
395
+ ### JSX Description
396
+
397
+ `description` accepts a string for the default text styling, or JSX when you need custom content such as a link:
398
+
399
+ ```tsx
400
+ <Modal
401
+ ref={modalRef}
402
+ heading="Update available"
403
+ description={
404
+ <BodyText>
405
+ Read the <InlineLink onPress={() => {}}>release notes</InlineLink> before updating.
406
+ </BodyText>
407
+ }
408
+ />
409
+ ```
410
+
384
411
  ### Sticky Custom Footer
385
412
 
386
413
  Replace the built-in buttons with a custom sticky footer when you need custom layouts or button arrangements:
@@ -10,7 +10,7 @@ export interface ModalCommonBaseProps {
10
10
  heading?: string;
11
11
  loadingHeading?: string;
12
12
  loadingDescription?: string;
13
- description?: string;
13
+ description?: ReactNode;
14
14
  stickyFooter?: boolean;
15
15
  children?: ViewProps['children'];
16
16
  onPressCloseButton?: () => void;
@@ -117,6 +117,8 @@ const Modal = ({
117
117
  const hasSecondaryButton = !!(onPressSecondaryButton && secondaryButtonText);
118
118
  const hasFooter = !!footer || hasPrimaryButton || hasSecondaryButton;
119
119
  const shouldShowFooter = !loading && hasFooter;
120
+ const descriptionIsText = typeof description === 'string' || typeof description === 'number';
121
+ const hasDescription = description !== undefined && description !== null;
120
122
 
121
123
  styles.useVariants({
122
124
  loading,
@@ -197,7 +199,13 @@ const Modal = ({
197
199
  {heading}
198
200
  </Heading>
199
201
  ) : null}
200
- {description && !image ? <BodyText accessible>{description}</BodyText> : null}
202
+ {hasDescription && !image ? (
203
+ descriptionIsText ? (
204
+ <BodyText accessible>{description}</BodyText>
205
+ ) : (
206
+ description
207
+ )
208
+ ) : null}
201
209
  </View>
202
210
  {showCloseButton ? (
203
211
  <UnstyledIconButton
@@ -217,10 +225,14 @@ const Modal = ({
217
225
  {heading}
218
226
  </Heading>
219
227
  ) : null}
220
- {description ? (
221
- <BodyText textAlign="center" accessible>
222
- {description}
223
- </BodyText>
228
+ {hasDescription ? (
229
+ descriptionIsText ? (
230
+ <BodyText textAlign="center" accessible>
231
+ {description}
232
+ </BodyText>
233
+ ) : (
234
+ description
235
+ )
224
236
  ) : null}
225
237
  </View>
226
238
  </View>
@@ -363,7 +375,7 @@ const styles = StyleSheet.create((theme, rt) => ({
363
375
  },
364
376
  imageContainer: {
365
377
  alignItems: 'center',
366
- flex: 1,
378
+ gap: theme.components.modal.content.gap,
367
379
  },
368
380
  textContent: {
369
381
  gap: theme.components.modal.content.gap,
@@ -96,6 +96,8 @@ const Modal = ({
96
96
  const hasPrimaryButton = !!(onPressPrimaryButton && primaryButtonText);
97
97
  const hasSecondaryButton = !!(onPressSecondaryButton && secondaryButtonText);
98
98
  const hasFooter = !!footer || hasPrimaryButton || hasSecondaryButton;
99
+ const descriptionIsText = typeof description === 'string' || typeof description === 'number';
100
+ const hasDescription = description !== undefined && description !== null;
99
101
 
100
102
  const footerContent = footer ?? (
101
103
  <View style={styles.footer}>
@@ -150,7 +152,13 @@ const Modal = ({
150
152
  {heading}
151
153
  </Heading>
152
154
  ) : null}
153
- {description && !image ? <BodyText accessible>{description}</BodyText> : null}
155
+ {hasDescription && !image ? (
156
+ descriptionIsText ? (
157
+ <BodyText accessible>{description}</BodyText>
158
+ ) : (
159
+ description
160
+ )
161
+ ) : null}
154
162
  </View>
155
163
  {showCloseButton ? (
156
164
  <UnstyledIconButton
@@ -170,10 +178,14 @@ const Modal = ({
170
178
  {heading}
171
179
  </Heading>
172
180
  ) : null}
173
- {description ? (
174
- <BodyText textAlign="center" accessible>
175
- {description}
176
- </BodyText>
181
+ {hasDescription ? (
182
+ descriptionIsText ? (
183
+ <BodyText textAlign="center" accessible>
184
+ {description}
185
+ </BodyText>
186
+ ) : (
187
+ description
188
+ )
177
189
  ) : null}
178
190
  </View>
179
191
  </View>
@@ -1,5 +1,5 @@
1
1
  import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks';
2
- import { BodyText, Button, Flex } from '../../';
2
+ import { BodyText, Button, Flex, InlineLink } from '../../';
3
3
  import StorybookLink from '../../../../../shared/storybook/StorybookLink';
4
4
  import modalAndroidVideo from '../../../docs/assets/modal-android.mp4';
5
5
  import modaliOSVideo from '../../../docs/assets/modal-ios.mp4';
@@ -139,7 +139,7 @@ const styles = StyleSheet.create({
139
139
  | Property | Type | Description | Default |
140
140
  | ------------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- |
141
141
  | `heading` | `string` | Heading text shown at the top of the modal when no `image` is provided. | - |
142
- | `description` | `string` | Supporting text shown below the heading when no `image` is provided. | - |
142
+ | `description` | `ReactNode` | Supporting text shown below the heading when no `image` is provided. Pass a string for the default styling, or JSX for custom content. | - |
143
143
  | `showCloseButton` | `boolean` | Whether to render the close button in the top-right corner. | `true` |
144
144
  | `primaryButtonText` | `string` | Label for the primary action button. | - |
145
145
  | `secondaryButtonText` | `string` | Label for the secondary action button. | - |
@@ -206,3 +206,18 @@ When `footer` is provided, the primary and secondary button props are not availa
206
206
  <BodyText>This sticky footer stays pinned while the content scrolls.</BodyText>
207
207
  </NavModal>
208
208
  ```
209
+
210
+ ### JSX Description
211
+
212
+ `description` accepts a string for the default text styling, or JSX when you need custom content such as a link.
213
+
214
+ ```tsx
215
+ <NavModal
216
+ heading="Update available"
217
+ description={
218
+ <BodyText>
219
+ Read the <InlineLink onPress={() => {}}>release notes</InlineLink> before updating.
220
+ </BodyText>
221
+ }
222
+ />
223
+ ```
@@ -4,6 +4,7 @@ import { BodyText } from '../BodyText';
4
4
  import { Box } from '../Box';
5
5
  import { Button } from '../Button';
6
6
  import { Flex } from '../Flex';
7
+ import { LI, UL } from '../HTMLElements';
7
8
  import NavModal from './NavModal';
8
9
 
9
10
  const meta = {
@@ -158,3 +159,42 @@ export const StickyCustomFooter: Story = {
158
159
  </View>
159
160
  ),
160
161
  };
162
+
163
+ export const WithJSXDescription: Story = {
164
+ render: () => (
165
+ <View style={Platform.OS === 'web' ? { width: 400, height: 720 } : { flex: 1 }}>
166
+ <NavModal
167
+ heading="Confirm changes"
168
+ description={
169
+ <>
170
+ <BodyText>
171
+ This example uses a JSX element for the description prop. You can use any React
172
+ component here, including links, lists, and other text components.
173
+ </BodyText>
174
+ <UL>
175
+ <LI>
176
+ <BodyText>Item 1</BodyText>
177
+ </LI>
178
+ </UL>
179
+ <UL>
180
+ <LI>
181
+ <BodyText>Item 2</BodyText>
182
+ </LI>
183
+ </UL>
184
+ <UL>
185
+ <LI>
186
+ <BodyText>Item 3</BodyText>
187
+ </LI>
188
+ </UL>
189
+ </>
190
+ }
191
+ >
192
+ <Box gap="200">
193
+ <BodyText>
194
+ Use the description prop to provide additional context or instructions.
195
+ </BodyText>
196
+ </Box>
197
+ </NavModal>
198
+ </View>
199
+ ),
200
+ };
@@ -115,6 +115,8 @@ const NavModal = ({
115
115
  const hasPrimaryButton = !!(onPressPrimaryButton && primaryButtonText);
116
116
  const hasSecondaryButton = !!(onPressSecondaryButton && secondaryButtonText);
117
117
  const hasFooter = !!footer || hasPrimaryButton || hasSecondaryButton;
118
+ const descriptionIsText = typeof description === 'string' || typeof description === 'number';
119
+ const hasDescription = description !== undefined && description !== null;
118
120
 
119
121
  styles.useVariants({
120
122
  loading,
@@ -201,10 +203,14 @@ const NavModal = ({
201
203
  {heading}
202
204
  </Heading>
203
205
  ) : null}
204
- {description && !image ? (
205
- <BodyText accessible inverted={isBrandBackground}>
206
- {description}
207
- </BodyText>
206
+ {hasDescription && !image ? (
207
+ descriptionIsText ? (
208
+ <BodyText accessible inverted={isBrandBackground}>
209
+ {description}
210
+ </BodyText>
211
+ ) : (
212
+ description
213
+ )
208
214
  ) : null}
209
215
  </View>
210
216
  {showCloseButton ? (
@@ -226,10 +232,14 @@ const NavModal = ({
226
232
  {heading}
227
233
  </Heading>
228
234
  ) : null}
229
- {description ? (
230
- <BodyText textAlign="center" accessible inverted={isBrandBackground}>
231
- {description}
232
- </BodyText>
235
+ {hasDescription ? (
236
+ descriptionIsText ? (
237
+ <BodyText textAlign="center" accessible inverted={isBrandBackground}>
238
+ {description}
239
+ </BodyText>
240
+ ) : (
241
+ description
242
+ )
233
243
  ) : null}
234
244
  </View>
235
245
  </View>