@utilitywarehouse/hearth-react-native 0.32.2 → 0.32.4

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 (48) hide show
  1. package/.storybook/main.ts +17 -7
  2. package/.storybook/manager.ts +4 -2
  3. package/.storybook/preview.tsx +4 -1
  4. package/.turbo/turbo-build.log +1 -4
  5. package/.turbo/{turbo-lint.log → turbo-lint$colon$fix.log} +11 -14
  6. package/CHANGELOG.md +36 -0
  7. package/build/components/Accordion/Accordion.d.ts +6 -6
  8. package/build/components/Banner/Banner.props.d.ts +2 -0
  9. package/build/components/Card/Card.d.ts +1 -1
  10. package/build/components/Card/CardAction/CardAction.d.ts +1 -1
  11. package/build/components/Checkbox/Checkbox.d.ts +1 -1
  12. package/build/components/ExpandableCard/ExpandableCardTrigger.d.ts +1 -1
  13. package/build/components/Icons/CircleIcon.d.ts +2 -2
  14. package/build/components/Input/InputField.js +1 -3
  15. package/build/components/Link/Link.d.ts +1 -1
  16. package/build/components/List/ListItem/ListItem.d.ts +1 -1
  17. package/build/components/Radio/Radio.d.ts +1 -1
  18. package/build/components/Rating/Rating.d.ts +1 -1
  19. package/build/components/Rating/Rating.js +40 -8
  20. package/build/components/Rating/Rating.props.d.ts +10 -2
  21. package/build/components/Rating/Rating.utils.d.ts +6 -0
  22. package/build/components/Rating/Rating.utils.js +7 -0
  23. package/build/components/Rating/Rating.utils.test.d.ts +1 -0
  24. package/build/components/Rating/Rating.utils.test.js +13 -0
  25. package/build/components/Rating/RatingEmoji.d.ts +3 -0
  26. package/build/components/Rating/RatingEmoji.js +21 -0
  27. package/build/components/Rating/index.d.ts +1 -1
  28. package/build/components/StepperInput/StepperButton.d.ts +1 -1
  29. package/build/components/Tabs/Tab.js +2 -2
  30. package/build/components/ToggleButton/ToggleButton.d.ts +2 -2
  31. package/docs/changelog.mdx +36 -0
  32. package/package.json +15 -11
  33. package/src/components/Banner/Banner.props.ts +2 -0
  34. package/src/components/ExpandableCard/ExpandableCardTrigger.tsx +0 -1
  35. package/src/components/Input/InputField.tsx +0 -1
  36. package/src/components/Rating/Rating.docs.mdx +44 -5
  37. package/src/components/Rating/Rating.figma.tsx +19 -0
  38. package/src/components/Rating/Rating.props.ts +8 -2
  39. package/src/components/Rating/Rating.stories.tsx +17 -0
  40. package/src/components/Rating/Rating.tsx +86 -28
  41. package/src/components/Rating/Rating.utils.test.ts +15 -0
  42. package/src/components/Rating/Rating.utils.ts +14 -0
  43. package/src/components/Rating/RatingEmoji.tsx +28 -0
  44. package/src/components/Rating/index.ts +1 -1
  45. package/src/components/Tabs/Tab.tsx +2 -3
  46. package/src/vite-env.d.ts +7 -0
  47. package/tsconfig.json +4 -0
  48. package/vitest.config.js +0 -2
@@ -1,5 +1,5 @@
1
- import { fileURLToPath } from "node:url";
2
- import { dirname } from "node:path";
1
+ import { fileURLToPath } from 'node:url';
2
+ import { dirname } from 'node:path';
3
3
  import remarkGfm from 'remark-gfm';
4
4
  import svgr from 'vite-plugin-svgr';
5
5
 
@@ -14,9 +14,9 @@ const unistylesPluginOptions = {
14
14
  const config = {
15
15
  stories: ['../**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
16
16
  addons: [
17
- getAbsolutePath("@chromatic-com/storybook"),
17
+ getAbsolutePath('@chromatic-com/storybook'),
18
18
  {
19
- name: getAbsolutePath("@storybook/addon-docs"),
19
+ name: getAbsolutePath('@storybook/addon-docs'),
20
20
  options: {
21
21
  mdxPluginOptions: {
22
22
  mdxCompileOptions: {
@@ -25,11 +25,11 @@ const config = {
25
25
  },
26
26
  },
27
27
  },
28
- getAbsolutePath("@storybook/addon-a11y"),
29
- getAbsolutePath("@storybook/addon-vitest"),
28
+ getAbsolutePath('@storybook/addon-a11y'),
29
+ getAbsolutePath('@storybook/addon-vitest'),
30
30
  ],
31
31
  framework: {
32
- name: getAbsolutePath("@storybook/react-native-web-vite"),
32
+ name: getAbsolutePath('@storybook/react-native-web-vite'),
33
33
  options: {
34
34
  pluginReactOptions: {
35
35
  babel: {
@@ -71,6 +71,16 @@ const config = {
71
71
  ...config.optimizeDeps,
72
72
  exclude: [...(config.optimizeDeps?.exclude || []), '@utilitywarehouse/hearth-svg-assets'],
73
73
  },
74
+ build: {
75
+ ...config.build,
76
+ rolldownOptions: {
77
+ ...config.build?.rolldownOptions,
78
+ external: [
79
+ ...(config.build?.rolldownOptions?.external || []),
80
+ '@react-stately/utils',
81
+ ],
82
+ },
83
+ },
74
84
  };
75
85
  },
76
86
  };
@@ -1,8 +1,10 @@
1
1
  import '@utilitywarehouse/hearth-fonts';
2
2
  import { addons } from 'storybook/manager-api';
3
- import '@utilitywarehouse/hearth-tokens/index.css';
4
3
  import '../../../shared/storybook/styles/manager.css';
5
- import theme from '../../../shared/storybook/theme';
4
+ import { config } from '../../../shared/storybook/theme';
5
+ import { create } from 'storybook/theming';
6
+
7
+ const theme = create(config);
6
8
 
7
9
  addons.setConfig({
8
10
  theme,
@@ -6,9 +6,12 @@ import { useEffect } from 'react';
6
6
  import { SafeAreaProvider } from 'react-native-safe-area-context';
7
7
  import '../../../shared/storybook/styles/diff-highlighting.css';
8
8
  import '../../../shared/storybook/styles/preview.css';
9
- import theme from '../../../shared/storybook/theme';
10
9
  import { breakpoints, StyleSheet, themes, UnistylesRuntime } from '../src/core';
11
10
  import { initializePrism } from './prism-setup';
11
+ import { config } from '../../../shared/storybook/theme';
12
+ import { create } from 'storybook/theming';
13
+
14
+ const theme = create(config);
12
15
 
13
16
  // Initialize Prism.js for syntax highlighting
14
17
  initializePrism();
@@ -1,4 +1 @@
1
-
2
- > @utilitywarehouse/hearth-react-native@0.32.2 build /home/runner/work/hearth/hearth/packages/react-native
3
- > tsc
4
-
1
+ $ tsc
@@ -1,7 +1,4 @@
1
-
2
- > @utilitywarehouse/hearth-react-native@0.32.2 lint /home/runner/work/hearth/hearth/packages/react-native
3
- > TIMING=1 eslint .
4
-
1
+ $ TIMING=1 eslint --fix .
5
2
 
6
3
  /home/runner/work/hearth/hearth/packages/react-native/src/components/Carousel/Carousel.context.tsx
7
4
  6:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components
@@ -47,13 +44,13 @@
47
44
 
48
45
  Rule | Time (ms) | Relative
49
46
  :----------------------------------------|----------:|--------:
50
- @typescript-eslint/no-unused-vars | 1652.894 | 62.0%
51
- react-hooks/exhaustive-deps | 139.717 | 5.2%
52
- react-hooks/rules-of-hooks | 103.240 | 3.9%
53
- no-global-assign | 68.299 | 2.6%
54
- no-misleading-character-class | 54.368 | 2.0%
55
- no-unexpected-multiline | 52.346 | 2.0%
56
- @typescript-eslint/ban-ts-comment | 50.938 | 1.9%
57
- no-useless-escape | 46.618 | 1.7%
58
- no-loss-of-precision | 37.323 | 1.4%
59
- @typescript-eslint/no-unused-expressions | 32.985 | 1.2%
47
+ @typescript-eslint/no-unused-vars | 2115.502 | 60.0%
48
+ react-hooks/rules-of-hooks | 141.299 | 4.0%
49
+ no-global-assign | 128.266 | 3.6%
50
+ react-hooks/exhaustive-deps | 107.897 | 3.1%
51
+ no-misleading-character-class | 72.815 | 2.1%
52
+ @typescript-eslint/ban-ts-comment | 70.843 | 2.0%
53
+ no-loss-of-precision | 55.192 | 1.6%
54
+ no-unexpected-multiline | 52.206 | 1.5%
55
+ no-regex-spaces | 50.033 | 1.4%
56
+ @typescript-eslint/no-unused-expressions | 49.226 | 1.4%
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # @utilitywarehouse/hearth-react-native
2
2
 
3
+ ## 0.32.4
4
+
5
+ ### Patch Changes
6
+
7
+ - [#1222](https://github.com/utilitywarehouse/hearth/pull/1222) [`3f35a43`](https://github.com/utilitywarehouse/hearth/commit/3f35a431ba4a0fae45cc640a62ea6cb53f85c384) Thanks [@Utakato](https://github.com/Utakato)! - 🌟 [FEATURE]: `Rating` component `emojis` variant
8
+
9
+ The `Rating` component now supports an `emojis` variant that renders emoji faces
10
+ instead of stars. When selected, the chosen emoji appears larger whilst
11
+ unselected emojis become grayscale. Two static labels ("Very dissatisfied" /
12
+ "Very satisfied") are displayed at the extremes.
13
+
14
+ **Components affected**:
15
+ - `Rating`
16
+
17
+ **Developer changes**:
18
+
19
+ ```tsx
20
+ import { Rating } from '@utilitywarehouse/hearth-react-native';
21
+
22
+ <Rating value={rating} onChange={setRating} variant="emojis" />;
23
+ ```
24
+
25
+ All existing props (`value`, `defaultValue`, `onChange`, `disabled`, `hideLabel`)
26
+ work with the emoji variant. The new `rangeLabels` prop allows overriding the
27
+ endpoint labels (defaulting to "Very dissatisfied" / "Very satisfied"):
28
+
29
+ ```tsx
30
+ <Rating variant="emojis" rangeLabels={{ low: 'Not at all', high: 'Absolutely' }} />
31
+ ```
32
+
33
+ ## 0.32.3
34
+
35
+ ### Patch Changes
36
+
37
+ - [#1192](https://github.com/utilitywarehouse/hearth/pull/1192) [`a74bf02`](https://github.com/utilitywarehouse/hearth/commit/a74bf02c58c12e1b42351e0d7f8e3e79ea0acbd6) Thanks [@robphoenix](https://github.com/robphoenix)! - 🧹 [HOUSEKEEPING]: Fix dependencies and types
38
+
3
39
  ## 0.32.2
4
40
 
5
41
  ### Patch Changes
@@ -1,19 +1,19 @@
1
1
  import { AccordionProps } from './Accordion.props';
2
2
  import { AccordionItemProps } from './AccordionItem.props';
3
- export declare const AccordionHeader: import("react").ForwardRefExoticComponent<import("react-native").ViewProps & import("react").RefAttributes<import("react-native").ViewProps>>;
4
- export declare const AccordionTrigger: import("react").ForwardRefExoticComponent<Omit<import("react-native").PressableProps & {
3
+ export declare const AccordionHeader: import("react").ForwardRefExoticComponent<import("react-native/types").ViewProps & import("react").RefAttributes<import("react-native/types").ViewProps>>;
4
+ export declare const AccordionTrigger: import("react").ForwardRefExoticComponent<Omit<import("react-native/types").PressableProps & {
5
5
  states?: {
6
6
  active?: boolean;
7
7
  };
8
- }, "children"> & import("@gluestack-ui/accordion/lib/typescript/types").IAccordionTriggerProps & import("react").RefAttributes<import("react-native").PressableProps & {
8
+ }, "children"> & import("@gluestack-ui/accordion/lib/typescript/types").IAccordionTriggerProps & import("react").RefAttributes<import("react-native/types").PressableProps & {
9
9
  states?: {
10
10
  active?: boolean;
11
11
  };
12
12
  }>>;
13
- export declare const AccordionContent: import("react").ForwardRefExoticComponent<import("react-native").ViewProps & import("react").RefAttributes<import("react-native").ViewProps>>;
14
- export declare const AccordionContentText: import("react").ForwardRefExoticComponent<import("react-native").TextProps & import("react").RefAttributes<import("react-native").TextProps>>;
13
+ export declare const AccordionContent: import("react").ForwardRefExoticComponent<import("react-native/types").ViewProps & import("react").RefAttributes<import("react-native/types").ViewProps>>;
14
+ export declare const AccordionContentText: import("react").ForwardRefExoticComponent<import("react-native/types").TextProps & import("react").RefAttributes<import("react-native/types").TextProps>>;
15
15
  export declare const AccordionIcon: import("react").ForwardRefExoticComponent<import("..").IconProps & import("react").RefAttributes<import("..").IconProps>>;
16
- export declare const AccordionTitleText: import("react").ForwardRefExoticComponent<import("react-native").TextProps & import("react").RefAttributes<import("react-native").TextProps>>;
16
+ export declare const AccordionTitleText: import("react").ForwardRefExoticComponent<import("react-native/types").TextProps & import("react").RefAttributes<import("react-native/types").TextProps>>;
17
17
  declare const Accordion: {
18
18
  ({ children, collapsible, type, heading, helperText, ...props }: AccordionProps): import("react/jsx-runtime").JSX.Element;
19
19
  displayName: string;
@@ -1,4 +1,5 @@
1
1
  import type { ComponentType, ReactElement, ReactNode } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
2
3
  import type CardProps from '../Card/Card.props';
3
4
  export type BannerDirection = 'horizontal' | 'vertical';
4
5
  export interface BannerProps extends Omit<CardProps, 'noPadding' | 'variant' | 'space' | 'gap' | 'rowGap' | 'columnGap' | 'flexDirection' | 'flexWrap' | 'alignItems' | 'justifyContent'> {
@@ -71,5 +72,6 @@ export interface BannerProps extends Omit<CardProps, 'noPadding' | 'variant' | '
71
72
  * @default 'center'
72
73
  */
73
74
  alignChevron?: 'center' | 'start' | 'end';
75
+ style?: StyleProp<ViewStyle>;
74
76
  }
75
77
  export default BannerProps;
@@ -3,7 +3,7 @@ declare const Card: import("react").ForwardRefExoticComponent<import("./Card.pro
3
3
  active?: boolean;
4
4
  disabled?: boolean;
5
5
  };
6
- } & Omit<import("react-native").PressableProps, "children"> & {
6
+ } & Omit<import("react-native/types").PressableProps, "children"> & {
7
7
  tabIndex?: 0 | -1 | undefined;
8
8
  } & {
9
9
  children?: import("react").ReactNode | (({ hovered, pressed, focused, focusVisible, disabled, }: {
@@ -4,7 +4,7 @@ declare const CardAction: import("react").ForwardRefExoticComponent<(((import(".
4
4
  disabled?: boolean;
5
5
  };
6
6
  isFirst?: boolean;
7
- }) & Omit<import("react-native").PressableProps, "children">) & {
7
+ }) & Omit<import("react-native/types").PressableProps, "children">) & {
8
8
  tabIndex?: 0 | -1 | undefined;
9
9
  } & {
10
10
  children?: import("react").ReactNode | (({ hovered, pressed, focused, focusVisible, disabled, }: {
@@ -4,7 +4,7 @@ declare const CheckboxGroup: import("react").ForwardRefExoticComponent<import("r
4
4
  }> & import("./CheckboxGroup.props").default & {
5
5
  isCard?: boolean;
6
6
  } & import("@gluestack-ui/checkbox/lib/typescript/types").ICheckboxGroup>;
7
- declare const CheckboxIndicator: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native").ViewProps> & import("react-native").ViewProps>;
7
+ declare const CheckboxIndicator: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native/types").ViewProps> & import("react-native/types").ViewProps>;
8
8
  declare const CheckboxIcon: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("..").IconProps> & import("..").IconProps & {
9
9
  forceMount?: boolean;
10
10
  }>;
@@ -3,7 +3,7 @@ declare const ExpandableCardTrigger: import("react").ForwardRefExoticComponent<(
3
3
  active?: boolean;
4
4
  disabled?: boolean;
5
5
  };
6
- }) & Omit<import("react-native").PressableProps, "children">) & {
6
+ }) & Omit<import("react-native/types").PressableProps, "children">) & {
7
7
  tabIndex?: 0 | -1 | undefined;
8
8
  } & {
9
9
  children?: import("react").ReactNode | (({ hovered, pressed, focused, focusVisible, disabled, }: {
@@ -1,5 +1,5 @@
1
1
  declare const CircleIcon: import("@gluestack-ui/icon/lib/typescript/createIcon").IIconComponentType<import("react-native-svg").SvgProps | {
2
- fill?: import("react-native").ColorValue | undefined;
3
- stroke?: import("react-native").ColorValue | undefined;
2
+ fill?: import("react-native/types").ColorValue | undefined;
3
+ stroke?: import("react-native/types").ColorValue | undefined;
4
4
  }>;
5
5
  export default CircleIcon;
@@ -10,9 +10,7 @@ const InputField = forwardRef(({ style, inBottomSheet = false, ...props }, ref)
10
10
  styles.useVariants({ focused, type });
11
11
  const { color } = useTheme();
12
12
  if (inBottomSheet) {
13
- return (
14
- // @ts-expect-error - BottomSheetTextInput has incompatible event types with TextInput
15
- _jsx(BottomSheetTextInput, { ref: ref, placeholderTextColor: color.text.secondary, selectionColor: color.surface.brand.default, cursorColor: color.surface.brand.default, verticalAlign: "middle", "aria-disabled": disabled, ...props, style: [styles.input, style] }));
13
+ return (_jsx(BottomSheetTextInput, { ref: ref, placeholderTextColor: color.text.secondary, selectionColor: color.surface.brand.default, cursorColor: color.surface.brand.default, verticalAlign: "middle", "aria-disabled": disabled, ...props, style: [styles.input, style] }));
16
14
  }
17
15
  return (_jsx(RNTextInput, { ref: ref, placeholderTextColor: color.text.secondary, selectionColor: color.surface.brand.default, cursorColor: color.surface.brand.default, verticalAlign: "middle", "aria-disabled": disabled, ...props, style: [styles.input, style] }));
18
16
  });
@@ -1,5 +1,5 @@
1
1
  import type { LinkProps } from './Link.props';
2
- export declare const LinkText: import("react").ForwardRefExoticComponent<import("react-native").TextProps & import("react").RefAttributes<import("react-native").TextProps>>;
2
+ export declare const LinkText: import("react").ForwardRefExoticComponent<import("react-native/types").TextProps & import("react").RefAttributes<import("react-native/types").TextProps>>;
3
3
  declare const Link: {
4
4
  ({ children, icon, disabled, target, iconPosition, showIcon, textStyle, iconStyle, ...props }: LinkProps): import("react/jsx-runtime").JSX.Element;
5
5
  displayName: string;
@@ -3,7 +3,7 @@ declare const ListItem: import("react").ForwardRefExoticComponent<(((import("./L
3
3
  active?: boolean;
4
4
  disabled?: boolean;
5
5
  };
6
- }) & Omit<import("react-native").PressableProps, "children">) & {
6
+ }) & Omit<import("react-native/types").PressableProps, "children">) & {
7
7
  tabIndex?: 0 | -1 | undefined;
8
8
  } & {
9
9
  children?: import("react").ReactNode | (({ hovered, pressed, focused, focusVisible, disabled, }: {
@@ -4,7 +4,7 @@ declare const RadioGroup: import("react").ForwardRefExoticComponent<import("reac
4
4
  }> & import("./RadioGroup.props").default & {
5
5
  isCard?: boolean;
6
6
  } & import("@gluestack-ui/radio/lib/typescript/types").IRadioGroupProps>;
7
- declare const RadioIndicator: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native").ViewProps> & import("react-native").ViewProps>;
7
+ declare const RadioIndicator: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native/types").ViewProps> & import("react-native/types").ViewProps>;
8
8
  declare const RadioIcon: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("..").IconProps> & import("..").IconProps & {
9
9
  forceMount?: boolean;
10
10
  }>;
@@ -1,6 +1,6 @@
1
1
  import type RatingProps from './Rating.props';
2
2
  declare const Rating: {
3
- ({ value, defaultValue, onChange, disabled, labels, hideLabel, style, accessibilityLabel, ...props }: RatingProps): import("react/jsx-runtime").JSX.Element;
3
+ ({ variant, value, defaultValue, onChange, disabled, labels, rangeLabels, hideLabel, style, accessibilityLabel, ...props }: RatingProps): import("react/jsx-runtime").JSX.Element;
4
4
  displayName: string;
5
5
  };
6
6
  export default Rating;
@@ -3,12 +3,21 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
3
3
  import { Pressable, View } from 'react-native';
4
4
  import { StyleSheet } from 'react-native-unistyles';
5
5
  import { BodyText } from '../BodyText';
6
+ import { getEmojiSvg } from './RatingEmoji';
6
7
  import RatingStarEmpty from './RatingStarEmpty';
7
8
  import RatingStarFilled from './RatingStarFilled';
9
+ import { EMOJI_LIST } from './Rating.utils';
8
10
  const MAX_RATING = 5;
9
11
  const STAR_WIDTH = 32;
10
12
  const STAR_HEIGHT = 30;
11
13
  const STAR_CONTAINER_SIZE = 40;
14
+ const EMOJI_SIZE_DEFAULT = 32;
15
+ const EMOJI_SIZE_SELECTED = 40;
16
+ const EMOJI_CONTAINER_SIZE = 44;
17
+ const DEFAULT_RANGE_LABELS = {
18
+ low: EMOJI_LIST[0].accessibilityLabel,
19
+ high: EMOJI_LIST[EMOJI_LIST.length - 1].accessibilityLabel,
20
+ };
12
21
  const DEFAULT_LABELS = {
13
22
  0: 'Select a rating',
14
23
  1: 'Awful',
@@ -18,7 +27,7 @@ const DEFAULT_LABELS = {
18
27
  5: 'Great!',
19
28
  };
20
29
  const clampRating = (value) => Math.min(MAX_RATING, Math.max(0, Math.round(value)));
21
- const Rating = ({ value, defaultValue = 0, onChange, disabled = false, labels, hideLabel = false, style, accessibilityLabel, ...props }) => {
30
+ const Rating = ({ variant = 'stars', value, defaultValue = 0, onChange, disabled = false, labels, rangeLabels = DEFAULT_RANGE_LABELS, hideLabel = false, style, accessibilityLabel, ...props }) => {
22
31
  const isControlled = value !== undefined;
23
32
  const [internalValue, setInternalValue] = useState(clampRating(defaultValue));
24
33
  useEffect(() => {
@@ -39,11 +48,22 @@ const Rating = ({ value, defaultValue = 0, onChange, disabled = false, labels, h
39
48
  onChange?.(nextValue);
40
49
  }, [disabled, isControlled, onChange]);
41
50
  styles.useVariants({ disabled });
42
- return (_jsxs(View, { ...props, accessibilityRole: "radiogroup", accessibilityState: { disabled }, accessibilityLabel: accessibilityLabel ?? currentLabel, style: [styles.container, style], children: [_jsx(View, { style: styles.stars, children: [1, 2, 3, 4, 5].map(starValue => {
43
- const isFilled = starValue <= resolvedValue;
44
- const starLabel = resolvedLabels[starValue] ?? DEFAULT_LABELS[starValue];
45
- return (_jsx(Pressable, { accessibilityRole: "radio", accessibilityState: { selected: resolvedValue === starValue, disabled }, accessibilityLabel: `Rate ${starLabel}`, disabled: disabled, hitSlop: 8, onPress: () => handlePress(starValue), style: styles.star, children: isFilled ? (_jsx(RatingStarFilled, { width: STAR_WIDTH, height: STAR_HEIGHT })) : (_jsx(RatingStarEmpty, { width: STAR_WIDTH, height: STAR_HEIGHT })) }, starValue));
46
- }) }), !hideLabel ? (_jsx(BodyText, { size: "md", color: labelColor, style: styles.label, children: currentLabel })) : null] }));
51
+ const isEmojis = variant === 'emojis';
52
+ const hasSelection = resolvedValue > 0;
53
+ const startLabel = rangeLabels.low;
54
+ const endLabel = rangeLabels.high;
55
+ return (_jsxs(View, { ...props, accessibilityRole: "radiogroup", accessibilityState: { disabled }, accessibilityLabel: accessibilityLabel ?? (isEmojis ? 'Rate your experience' : currentLabel), style: [styles.container, style], children: [_jsx(View, { style: styles.items, children: isEmojis
56
+ ? EMOJI_LIST.map(entry => {
57
+ const isSelected = resolvedValue === entry.value;
58
+ const size = isSelected ? EMOJI_SIZE_SELECTED : EMOJI_SIZE_DEFAULT;
59
+ const EmojiSvg = getEmojiSvg(entry.value, hasSelection && !isSelected);
60
+ return (_jsx(Pressable, { accessibilityRole: "radio", accessibilityState: { selected: isSelected, disabled }, accessibilityLabel: `Rate ${entry.accessibilityLabel}`, disabled: disabled, hitSlop: 8, onPress: () => handlePress(entry.value), style: styles.emojiItem, children: _jsx(EmojiSvg, { width: size, height: size }) }, entry.value));
61
+ })
62
+ : [1, 2, 3, 4, 5].map(starValue => {
63
+ const isFilled = starValue <= resolvedValue;
64
+ const starLabel = resolvedLabels[starValue] ?? DEFAULT_LABELS[starValue];
65
+ return (_jsx(Pressable, { accessibilityRole: "radio", accessibilityState: { selected: resolvedValue === starValue, disabled }, accessibilityLabel: `Rate ${starLabel}`, disabled: disabled, hitSlop: 8, onPress: () => handlePress(starValue), style: styles.starItem, children: isFilled ? (_jsx(RatingStarFilled, { width: STAR_WIDTH, height: STAR_HEIGHT })) : (_jsx(RatingStarEmpty, { width: STAR_WIDTH, height: STAR_HEIGHT })) }, starValue));
66
+ }) }), isEmojis ? (!hideLabel ? (_jsxs(View, { style: styles.emojiLabels, children: [_jsx(BodyText, { size: "md", color: "secondary", children: startLabel }), _jsx(BodyText, { size: "md", color: "secondary", children: endLabel })] })) : null) : !hideLabel ? (_jsx(BodyText, { size: "md", color: labelColor, style: styles.label, children: currentLabel })) : null] }));
47
67
  };
48
68
  Rating.displayName = 'Rating';
49
69
  const styles = StyleSheet.create(theme => ({
@@ -58,19 +78,31 @@ const styles = StyleSheet.create(theme => ({
58
78
  },
59
79
  },
60
80
  },
61
- stars: {
81
+ items: {
62
82
  flexDirection: 'row',
63
83
  gap: theme.components.rating.gap,
64
84
  },
65
- star: {
85
+ starItem: {
66
86
  width: STAR_CONTAINER_SIZE,
67
87
  height: STAR_CONTAINER_SIZE,
68
88
  alignItems: 'center',
69
89
  justifyContent: 'center',
70
90
  padding: theme.components.rating.borderWidth,
71
91
  },
92
+ emojiItem: {
93
+ width: EMOJI_CONTAINER_SIZE,
94
+ height: EMOJI_CONTAINER_SIZE,
95
+ alignItems: 'center',
96
+ justifyContent: 'center',
97
+ padding: theme.components.rating.borderWidth,
98
+ },
72
99
  label: {
73
100
  textAlign: 'center',
74
101
  },
102
+ emojiLabels: {
103
+ flexDirection: 'row',
104
+ justifyContent: 'space-between',
105
+ width: '100%',
106
+ },
75
107
  }));
76
108
  export default Rating;
@@ -1,18 +1,26 @@
1
1
  import type { ViewProps } from 'react-native';
2
2
  export type RatingValue = 0 | 1 | 2 | 3 | 4 | 5;
3
3
  export type RatingLabels = Partial<Record<RatingValue, string>>;
4
+ export type RatingVariant = 'stars' | 'emojis';
4
5
  export interface RatingProps extends Omit<ViewProps, 'children'> {
6
+ /** Visual variant for the rating indicators. */
7
+ variant?: RatingVariant;
5
8
  /** Current rating value. */
6
9
  value?: RatingValue;
7
10
  /** Initial rating value when uncontrolled. */
8
11
  defaultValue?: RatingValue;
9
- /** Called when a star is selected. */
12
+ /** Called when a rating is selected. */
10
13
  onChange?: (value: RatingValue) => void;
11
14
  /** Disables the rating input. */
12
15
  disabled?: boolean;
13
16
  /** Override labels for specific rating values. */
14
17
  labels?: RatingLabels;
15
- /** Hide the label text below the stars. */
18
+ /** Override the low and high end labels shown below the emoji variant. */
19
+ rangeLabels?: {
20
+ low: string;
21
+ high: string;
22
+ };
23
+ /** Hide the label text below the rating. */
16
24
  hideLabel?: boolean;
17
25
  }
18
26
  export default RatingProps;
@@ -0,0 +1,6 @@
1
+ import type { RatingValue } from './Rating.props';
2
+ export interface EmojiEntry {
3
+ value: Exclude<RatingValue, 0>;
4
+ accessibilityLabel: string;
5
+ }
6
+ export declare const EMOJI_LIST: EmojiEntry[];
@@ -0,0 +1,7 @@
1
+ export const EMOJI_LIST = [
2
+ { value: 1, accessibilityLabel: 'Very dissatisfied' },
3
+ { value: 2, accessibilityLabel: 'Dissatisfied' },
4
+ { value: 3, accessibilityLabel: 'Neutral' },
5
+ { value: 4, accessibilityLabel: 'Satisfied' },
6
+ { value: 5, accessibilityLabel: 'Very satisfied' },
7
+ ];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { EMOJI_LIST } from './Rating.utils';
3
+ describe('EMOJI_LIST', () => {
4
+ it('has exactly 5 emojis mapped to values 1-5', () => {
5
+ expect(EMOJI_LIST).toHaveLength(5);
6
+ expect(EMOJI_LIST.map(e => e.value)).toEqual([1, 2, 3, 4, 5]);
7
+ });
8
+ it('each entry has an accessibility label', () => {
9
+ for (const entry of EMOJI_LIST) {
10
+ expect(entry.accessibilityLabel).toBeTruthy();
11
+ }
12
+ });
13
+ });
@@ -0,0 +1,3 @@
1
+ import type { FC } from 'react';
2
+ import type { SvgProps } from 'react-native-svg';
3
+ export declare const getEmojiSvg: (value: 1 | 2 | 3 | 4 | 5, grayscale: boolean) => FC<SvgProps>;
@@ -0,0 +1,21 @@
1
+ import BeamingFace from '@utilitywarehouse/hearth-svg-assets/lib/beaming-face.svg';
2
+ import BeamingFaceGrey from '@utilitywarehouse/hearth-svg-assets/lib/beaming-face-grey.svg';
3
+ import DissapointedFace from '@utilitywarehouse/hearth-svg-assets/lib/dissapointed-face.svg';
4
+ import DissapointedFaceGrey from '@utilitywarehouse/hearth-svg-assets/lib/dissapointed-face-grey.svg';
5
+ import FrowningFace from '@utilitywarehouse/hearth-svg-assets/lib/frowning-face.svg';
6
+ import FrowningFaceGrey from '@utilitywarehouse/hearth-svg-assets/lib/frowning-face-grey.svg';
7
+ import NeutralFace from '@utilitywarehouse/hearth-svg-assets/lib/neutral-face.svg';
8
+ import NeutralFaceGrey from '@utilitywarehouse/hearth-svg-assets/lib/neutral-face-grey.svg';
9
+ import SlightlySmilingFace from '@utilitywarehouse/hearth-svg-assets/lib/slightly-smiling-face.svg';
10
+ import SlightlySmilingFaceGrey from '@utilitywarehouse/hearth-svg-assets/lib/slightly-smiling-face-grey.svg';
11
+ const EMOJI_ASSETS = {
12
+ 1: { color: DissapointedFace, grey: DissapointedFaceGrey },
13
+ 2: { color: FrowningFace, grey: FrowningFaceGrey },
14
+ 3: { color: NeutralFace, grey: NeutralFaceGrey },
15
+ 4: { color: SlightlySmilingFace, grey: SlightlySmilingFaceGrey },
16
+ 5: { color: BeamingFace, grey: BeamingFaceGrey },
17
+ };
18
+ export const getEmojiSvg = (value, grayscale) => {
19
+ const assets = EMOJI_ASSETS[value];
20
+ return grayscale ? assets.grey : assets.color;
21
+ };
@@ -1,2 +1,2 @@
1
1
  export { default as Rating } from './Rating';
2
- export type { RatingLabels, RatingProps, RatingValue } from './Rating.props';
2
+ export type { RatingLabels, RatingProps, RatingValue, RatingVariant } from './Rating.props';
@@ -8,7 +8,7 @@ declare const StepperButton: import("react").ForwardRefExoticComponent<{
8
8
  active?: boolean;
9
9
  disabled?: boolean;
10
10
  };
11
- } & Omit<import("react-native").PressableProps, "children"> & {
11
+ } & {
12
12
  tabIndex?: 0 | -1 | undefined;
13
13
  } & {
14
14
  children?: import("react").ReactNode | (({ hovered, pressed, focused, focusVisible, disabled, }: {
@@ -1,11 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createPressable } from '@gluestack-ui/pressable';
2
3
  import { useCallback, useRef } from 'react';
3
4
  import { Platform, Pressable, View } from 'react-native';
4
5
  import { StyleSheet } from 'react-native-unistyles';
6
+ import { BodyText } from '../BodyText';
5
7
  import { Icon } from '../Icon';
6
8
  import { useTabsContext } from './Tabs.context';
7
- import { createPressable } from '@gluestack-ui/pressable';
8
- import { BodyText } from '../BodyText';
9
9
  const Tab = ({ value, children, icon, disabled, style, states, ...props }) => {
10
10
  const { value: active, select, size, disabled: allDisabled, registerTabLayout, } = useTabsContext();
11
11
  const { active: pressed } = states || { active: false };
@@ -1,7 +1,7 @@
1
1
  import type { ToggleButtonProps } from './ToggleButton.props';
2
- export declare const ToggleButtonText: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native").TextProps & {
2
+ export declare const ToggleButtonText: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native/types").TextProps & {
3
3
  toggled: boolean;
4
- }> & import("react-native").TextProps & {
4
+ }> & import("react-native/types").TextProps & {
5
5
  toggled: boolean;
6
6
  }>;
7
7
  export declare const ToggleButtonIcon: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("..").IconProps & {
@@ -9,6 +9,42 @@ 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.32.4
13
+
14
+ ### Patch Changes
15
+
16
+ - [#1222](https://github.com/utilitywarehouse/hearth/pull/1222) [`3f35a43`](https://github.com/utilitywarehouse/hearth/commit/3f35a431ba4a0fae45cc640a62ea6cb53f85c384) Thanks [@Utakato](https://github.com/Utakato)! - 🌟 [FEATURE]: `Rating` component `emojis` variant
17
+
18
+ The `Rating` component now supports an `emojis` variant that renders emoji faces
19
+ instead of stars. When selected, the chosen emoji appears larger whilst
20
+ unselected emojis become grayscale. Two static labels ("Very dissatisfied" /
21
+ "Very satisfied") are displayed at the extremes.
22
+
23
+ **Components affected**:
24
+ - `Rating`
25
+
26
+ **Developer changes**:
27
+
28
+ ```tsx
29
+ import { Rating } from '@utilitywarehouse/hearth-react-native';
30
+
31
+ <Rating value={rating} onChange={setRating} variant="emojis" />;
32
+ ```
33
+
34
+ All existing props (`value`, `defaultValue`, `onChange`, `disabled`, `hideLabel`)
35
+ work with the emoji variant. The new `rangeLabels` prop allows overriding the
36
+ endpoint labels (defaulting to "Very dissatisfied" / "Very satisfied"):
37
+
38
+ ```tsx
39
+ <Rating variant="emojis" rangeLabels={{ low: 'Not at all', high: 'Absolutely' }} />
40
+ ```
41
+
42
+ ## 0.32.3
43
+
44
+ ### Patch Changes
45
+
46
+ - [#1192](https://github.com/utilitywarehouse/hearth/pull/1192) [`a74bf02`](https://github.com/utilitywarehouse/hearth/commit/a74bf02c58c12e1b42351e0d7f8e3e79ea0acbd6) Thanks [@robphoenix](https://github.com/robphoenix)! - 🧹 [HOUSEKEEPING]: Fix dependencies and types
47
+
12
48
  ## 0.32.2
13
49
 
14
50
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@utilitywarehouse/hearth-react-native",
3
- "version": "0.32.2",
3
+ "version": "0.32.4",
4
4
  "description": "Utility Warehouse React Native UI library",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -12,6 +12,7 @@
12
12
  "@gluestack-ui/alert": "0.1.15",
13
13
  "@gluestack-ui/button": "1.0.7",
14
14
  "@gluestack-ui/checkbox": "0.1.32",
15
+ "@gluestack-ui/form-control": "0.1.19",
15
16
  "@gluestack-ui/icon": "0.1.22",
16
17
  "@gluestack-ui/input": "0.1.31",
17
18
  "@gluestack-ui/link": "0.1.22",
@@ -21,25 +22,28 @@
21
22
  "@gluestack-ui/switch": "0.1.22",
22
23
  "@gluestack-ui/textarea": "0.1.23",
23
24
  "@quidone/react-native-wheel-picker": "^1.6.1",
24
- "dayjs": "^1.11.13"
25
+ "dayjs": "^1.11.13",
26
+ "nanoid": "3.3.11"
25
27
  },
26
28
  "devDependencies": {
27
29
  "@babel/plugin-proposal-export-namespace-from": "^7.18.9",
28
30
  "@chromatic-com/storybook": "^4.1.3",
29
31
  "@figma/code-connect": "^1.3.12",
30
32
  "@gorhom/bottom-sheet": "5.2.6",
33
+ "@react-stately/utils": "^3.12.0",
31
34
  "@storybook/addon-a11y": "^10.2.1",
32
35
  "@storybook/addon-docs": "^10.2.1",
33
36
  "@storybook/addon-vitest": "^10.2.1",
34
37
  "@storybook/react-native-web-vite": "^10.2.1",
35
38
  "@types/prismjs": "^1.26.5",
36
- "@types/react-dom": "^19.1.6",
37
39
  "@types/react": "^19.1.10",
40
+ "@types/react-dom": "^19.1.6",
41
+ "@types/react-native": "^0.72.8",
38
42
  "@vitest/browser": "^3.2.4",
39
43
  "@vitest/coverage-v8": "^3.2.4",
40
44
  "chromatic": "^13.3.0",
41
- "eslint-plugin-storybook": "10.2.1",
42
- "playwright": "^1.55.1",
45
+ "globals": "^15.15.0",
46
+ "playwright": "^1.59.1",
43
47
  "prismjs": "^1.30.0",
44
48
  "react": "^19.1.0",
45
49
  "react-dom": "^19.1.0",
@@ -60,9 +64,9 @@
60
64
  "vite-plugin-svgr": "^4.5.0",
61
65
  "vitest": "^3.2.4",
62
66
  "@utilitywarehouse/hearth-fonts": "^0.0.4",
63
- "@utilitywarehouse/hearth-react-icons": "^0.8.1",
67
+ "@utilitywarehouse/hearth-react-icons": "^0.8.2",
64
68
  "@utilitywarehouse/hearth-react-native-icons": "^0.8.1",
65
- "@utilitywarehouse/hearth-svg-assets": "^0.6.1",
69
+ "@utilitywarehouse/hearth-svg-assets": "^0.6.2",
66
70
  "@utilitywarehouse/hearth-tokens": "^0.2.4"
67
71
  },
68
72
  "peerDependencies": {
@@ -90,10 +94,10 @@
90
94
  "figma:publish": "figma connect publish",
91
95
  "test": "vitest run --config vitest.unit.config.ts",
92
96
  "test:storybook": "vitest run --project storybook",
93
- "dev": "npm run copy:changelog && storybook dev -p 6006",
94
- "dev:docs": "storybook dev -p 6002 --no-open --docs",
95
- "build:storybook": "npm run copy:changelog && storybook build",
96
- "build:storybook:docs": "npm run copy:changelog && storybook build --docs",
97
+ "dev": "storybook dev -p 6006",
98
+ "dev:docs": "storybook dev -p 6002 --no-open",
99
+ "build:storybook": "storybook build",
100
+ "build:storybook:docs": "storybook build --docs",
97
101
  "chromatic": "npx chromatic --project-token=chpt_cce0fb1ebd95d2a --build-script-name build:storybook"
98
102
  }
99
103
  }
@@ -1,4 +1,5 @@
1
1
  import type { ComponentType, ReactElement, ReactNode } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
2
3
  import type CardProps from '../Card/Card.props';
3
4
 
4
5
  export type BannerDirection = 'horizontal' | 'vertical';
@@ -92,6 +93,7 @@ export interface BannerProps extends Omit<
92
93
  * @default 'center'
93
94
  */
94
95
  alignChevron?: 'center' | 'start' | 'end';
96
+ style?: StyleProp<ViewStyle>;
95
97
  }
96
98
 
97
99
  export default BannerProps;
@@ -4,7 +4,6 @@ import ExpandableCardTriggerRoot from './ExpandableCardTriggerRoot';
4
4
  const ExpandableCardTrigger = createPressable({
5
5
  Root: ExpandableCardTriggerRoot,
6
6
  });
7
-
8
7
  ExpandableCardTrigger.displayName = 'ExpandableCardTrigger';
9
8
 
10
9
  export default ExpandableCardTrigger;
@@ -13,7 +13,6 @@ const InputField = forwardRef<RNTextInput, TextInputProps & { inBottomSheet?: bo
13
13
 
14
14
  if (inBottomSheet) {
15
15
  return (
16
- // @ts-expect-error - BottomSheetTextInput has incompatible event types with TextInput
17
16
  <BottomSheetTextInput
18
17
  ref={ref as any}
19
18
  placeholderTextColor={color.text.secondary}
@@ -11,12 +11,13 @@ import * as Stories from './Rating.stories';
11
11
 
12
12
  # Rating
13
13
 
14
- Use Rating to collect a star-based score with an optional descriptive label.
14
+ Use Rating to collect a score with an optional descriptive label. Supports star and emoji variants.
15
15
 
16
16
  - [Playground](#playground)
17
17
  - [Usage](#usage)
18
18
  - [Props](#props)
19
19
  - [Examples](#examples)
20
+ - [Emoji variant](#emoji-variant)
20
21
  - [Accessibility](#accessibility)
21
22
 
22
23
  ## Playground
@@ -52,10 +53,12 @@ const MyComponent = () => {
52
53
  | -------------- | -------------------------------------- | ------------------------------------------- | ----------- |
53
54
  | `value` | `0 \| 1 \| 2 \| 3 \| 4 \| 5` | Current rating value. | `0` |
54
55
  | `defaultValue` | `0 \| 1 \| 2 \| 3 \| 4 \| 5` | Initial rating value when uncontrolled. | `0` |
55
- | `onChange` | `(value: RatingValue) => void` | Called when a star is selected. | `undefined` |
56
+ | `onChange` | `(value: RatingValue) => void` | Called when a rating is selected. | `undefined` |
56
57
  | `disabled` | `boolean` | Disables the rating input. | `false` |
57
58
  | `labels` | `Partial<Record<RatingValue, string>>` | Override labels for specific rating values. | `undefined` |
58
- | `hideLabel` | `boolean` | Hide the label text below the stars. | `false` |
59
+ | `rangeLabels` | `{ low: string; high: string }` | Override the low and high end labels shown below the emoji variant. | `{ low: 'Very dissatisfied', high: 'Very satisfied' }` |
60
+ | `hideLabel` | `boolean` | Hide the label text below the rating. | `false` |
61
+ | `variant` | `'stars' \| 'emojis'` | Visual variant for the rating indicators. | `'stars'` |
59
62
 
60
63
  ## Examples
61
64
 
@@ -171,8 +174,44 @@ import { Rating } from '@utilitywarehouse/hearth-react-native';
171
174
  const MyComponent = () => <Rating value={4} disabled />;
172
175
  ```
173
176
 
177
+ ### Emoji variant
178
+
179
+ Use `variant="emojis"` to show emoji faces instead of stars. Labels default to "Very dissatisfied" and "Very satisfied" at the extremes. Override them with `rangeLabels={{ low: 'Custom low', high: 'Custom high' }}`.
180
+
181
+ <UsageWrap>
182
+ <Center>
183
+ <Box>
184
+ <Rating value={4} variant="emojis" />
185
+ </Box>
186
+ </Center>
187
+ </UsageWrap>
188
+
189
+ ```tsx
190
+ import { Rating } from '@utilitywarehouse/hearth-react-native';
191
+
192
+ const MyComponent = () => <Rating value={4} variant="emojis" />;
193
+ ```
194
+
195
+ ### Emoji variant (no selection)
196
+
197
+ When no emoji is selected, all emojis display at normal size with full colour.
198
+
199
+ <UsageWrap>
200
+ <Center>
201
+ <Box>
202
+ <Rating value={0} variant="emojis" />
203
+ </Box>
204
+ </Center>
205
+ </UsageWrap>
206
+
207
+ ```tsx
208
+ import { Rating } from '@utilitywarehouse/hearth-react-native';
209
+
210
+ const MyComponent = () => <Rating value={0} variant="emojis" />;
211
+ ```
212
+
174
213
  ## Accessibility
175
214
 
176
- - Rating uses a `radiogroup` container with `radio` items for each star.
177
- - Each star announces a descriptive label (e.g., "Rate Okay"). Override labels with the `labels` prop to match your content.
215
+ - Rating uses a `radiogroup` container with `radio` items for each star or emoji.
216
+ - Each star announces a descriptive label (e.g., "Rate Okay"). Each emoji announces its sentiment (e.g., "Rate Neutral"). Override labels with the `labels` prop to match your content.
178
217
  - Provide `accessibilityLabel` when the default label text is not sufficient for your screen reader context.
@@ -18,3 +18,22 @@ figma.connect(
18
18
  example: props => <Rating value={props.value} />,
19
19
  }
20
20
  );
21
+
22
+ figma.connect(
23
+ Rating,
24
+ 'https://www.figma.com/design/6NKZXZhFSExXrcbBgc6zTR/Hearth-Components---Tokens?node-id=10620-4185',
25
+ {
26
+ variant: { Variant: 'Emojis' },
27
+ props: {
28
+ value: figma.enum('Rating', {
29
+ '0 Star': 0,
30
+ '1 Star': 1,
31
+ '2 Star': 2,
32
+ '3 Star': 3,
33
+ '4 Star': 4,
34
+ '5 Star': 5,
35
+ }),
36
+ },
37
+ example: props => <Rating value={props.value} variant="emojis" />,
38
+ }
39
+ );
@@ -4,18 +4,24 @@ export type RatingValue = 0 | 1 | 2 | 3 | 4 | 5;
4
4
 
5
5
  export type RatingLabels = Partial<Record<RatingValue, string>>;
6
6
 
7
+ export type RatingVariant = 'stars' | 'emojis';
8
+
7
9
  export interface RatingProps extends Omit<ViewProps, 'children'> {
10
+ /** Visual variant for the rating indicators. */
11
+ variant?: RatingVariant;
8
12
  /** Current rating value. */
9
13
  value?: RatingValue;
10
14
  /** Initial rating value when uncontrolled. */
11
15
  defaultValue?: RatingValue;
12
- /** Called when a star is selected. */
16
+ /** Called when a rating is selected. */
13
17
  onChange?: (value: RatingValue) => void;
14
18
  /** Disables the rating input. */
15
19
  disabled?: boolean;
16
20
  /** Override labels for specific rating values. */
17
21
  labels?: RatingLabels;
18
- /** Hide the label text below the stars. */
22
+ /** Override the low and high end labels shown below the emoji variant. */
23
+ rangeLabels?: { low: string; high: string };
24
+ /** Hide the label text below the rating. */
19
25
  hideLabel?: boolean;
20
26
  }
21
27
 
@@ -23,17 +23,25 @@ const meta = {
23
23
  labels: {
24
24
  control: 'object',
25
25
  },
26
+ rangeLabels: {
27
+ control: 'object',
28
+ },
26
29
  hideLabel: {
27
30
  control: 'boolean',
28
31
  },
29
32
  disabled: {
30
33
  control: 'boolean',
31
34
  },
35
+ variant: {
36
+ options: ['stars', 'emojis'],
37
+ control: 'radio',
38
+ },
32
39
  },
33
40
  args: {
34
41
  value: 3,
35
42
  hideLabel: false,
36
43
  disabled: false,
44
+ variant: 'stars',
37
45
  },
38
46
  } satisfies Meta<typeof Rating>;
39
47
 
@@ -90,6 +98,15 @@ export const Variants: Story = {
90
98
  <VariantTitle title="Disabled">
91
99
  <Rating value={5} disabled />
92
100
  </VariantTitle>
101
+ <VariantTitle title="Emojis">
102
+ <Rating value={4} variant="emojis" />
103
+ </VariantTitle>
104
+ <VariantTitle title="Emojis (No Selection)">
105
+ <Rating value={0} variant="emojis" />
106
+ </VariantTitle>
107
+ <VariantTitle title="Emojis (Disabled)">
108
+ <Rating value={3} variant="emojis" disabled />
109
+ </VariantTitle>
93
110
  </Box>
94
111
  ),
95
112
  };
@@ -4,13 +4,23 @@ import { StyleSheet } from 'react-native-unistyles';
4
4
  import { BodyText } from '../BodyText';
5
5
  import type RatingProps from './Rating.props';
6
6
  import type { RatingLabels, RatingValue } from './Rating.props';
7
+ import { getEmojiSvg } from './RatingEmoji';
7
8
  import RatingStarEmpty from './RatingStarEmpty';
8
9
  import RatingStarFilled from './RatingStarFilled';
10
+ import { EMOJI_LIST } from './Rating.utils';
9
11
 
10
12
  const MAX_RATING: RatingValue = 5;
11
13
  const STAR_WIDTH = 32;
12
14
  const STAR_HEIGHT = 30;
13
15
  const STAR_CONTAINER_SIZE = 40;
16
+ const EMOJI_SIZE_DEFAULT = 32;
17
+ const EMOJI_SIZE_SELECTED = 40;
18
+ const EMOJI_CONTAINER_SIZE = 44;
19
+
20
+ const DEFAULT_RANGE_LABELS = {
21
+ low: EMOJI_LIST[0].accessibilityLabel,
22
+ high: EMOJI_LIST[EMOJI_LIST.length - 1].accessibilityLabel,
23
+ };
14
24
 
15
25
  const DEFAULT_LABELS: Record<RatingValue, string> = {
16
26
  0: 'Select a rating',
@@ -25,11 +35,13 @@ const clampRating = (value: number) =>
25
35
  Math.min(MAX_RATING, Math.max(0, Math.round(value))) as RatingValue;
26
36
 
27
37
  const Rating = ({
38
+ variant = 'stars',
28
39
  value,
29
40
  defaultValue = 0,
30
41
  onChange,
31
42
  disabled = false,
32
43
  labels,
44
+ rangeLabels = DEFAULT_RANGE_LABELS,
33
45
  hideLabel = false,
34
46
  style,
35
47
  accessibilityLabel,
@@ -65,40 +77,74 @@ const Rating = ({
65
77
 
66
78
  styles.useVariants({ disabled });
67
79
 
80
+ const isEmojis = variant === 'emojis';
81
+ const hasSelection = resolvedValue > 0;
82
+
83
+ const startLabel = rangeLabels.low;
84
+ const endLabel = rangeLabels.high;
85
+
68
86
  return (
69
87
  <View
70
88
  {...props}
71
89
  accessibilityRole="radiogroup"
72
90
  accessibilityState={{ disabled }}
73
- accessibilityLabel={accessibilityLabel ?? currentLabel}
91
+ accessibilityLabel={accessibilityLabel ?? (isEmojis ? 'Rate your experience' : currentLabel)}
74
92
  style={[styles.container, style]}
75
93
  >
76
- <View style={styles.stars}>
77
- {([1, 2, 3, 4, 5] as RatingValue[]).map(starValue => {
78
- const isFilled = starValue <= resolvedValue;
79
- const starLabel = resolvedLabels[starValue] ?? DEFAULT_LABELS[starValue];
80
-
81
- return (
82
- <Pressable
83
- key={starValue}
84
- accessibilityRole="radio"
85
- accessibilityState={{ selected: resolvedValue === starValue, disabled }}
86
- accessibilityLabel={`Rate ${starLabel}`}
87
- disabled={disabled}
88
- hitSlop={8}
89
- onPress={() => handlePress(starValue)}
90
- style={styles.star}
91
- >
92
- {isFilled ? (
93
- <RatingStarFilled width={STAR_WIDTH} height={STAR_HEIGHT} />
94
- ) : (
95
- <RatingStarEmpty width={STAR_WIDTH} height={STAR_HEIGHT} />
96
- )}
97
- </Pressable>
98
- );
99
- })}
94
+ <View style={styles.items}>
95
+ {isEmojis
96
+ ? EMOJI_LIST.map(entry => {
97
+ const isSelected = resolvedValue === entry.value;
98
+ const size = isSelected ? EMOJI_SIZE_SELECTED : EMOJI_SIZE_DEFAULT;
99
+ const EmojiSvg = getEmojiSvg(entry.value, hasSelection && !isSelected);
100
+
101
+ return (
102
+ <Pressable
103
+ key={entry.value}
104
+ accessibilityRole="radio"
105
+ accessibilityState={{ selected: isSelected, disabled }}
106
+ accessibilityLabel={`Rate ${entry.accessibilityLabel}`}
107
+ disabled={disabled}
108
+ hitSlop={8}
109
+ onPress={() => handlePress(entry.value)}
110
+ style={styles.emojiItem}
111
+ >
112
+ <EmojiSvg width={size} height={size} />
113
+ </Pressable>
114
+ );
115
+ })
116
+ : ([1, 2, 3, 4, 5] as RatingValue[]).map(starValue => {
117
+ const isFilled = starValue <= resolvedValue;
118
+ const starLabel = resolvedLabels[starValue] ?? DEFAULT_LABELS[starValue];
119
+
120
+ return (
121
+ <Pressable
122
+ key={starValue}
123
+ accessibilityRole="radio"
124
+ accessibilityState={{ selected: resolvedValue === starValue, disabled }}
125
+ accessibilityLabel={`Rate ${starLabel}`}
126
+ disabled={disabled}
127
+ hitSlop={8}
128
+ onPress={() => handlePress(starValue)}
129
+ style={styles.starItem}
130
+ >
131
+ {isFilled ? (
132
+ <RatingStarFilled width={STAR_WIDTH} height={STAR_HEIGHT} />
133
+ ) : (
134
+ <RatingStarEmpty width={STAR_WIDTH} height={STAR_HEIGHT} />
135
+ )}
136
+ </Pressable>
137
+ );
138
+ })}
100
139
  </View>
101
- {!hideLabel ? (
140
+ {isEmojis ? (
141
+ !hideLabel ? (
142
+ <View style={styles.emojiLabels}>
143
+ <BodyText size="md" color="secondary">{startLabel}</BodyText>
144
+ <BodyText size="md" color="secondary">{endLabel}</BodyText>
145
+ </View>
146
+ ) : null
147
+ ) : !hideLabel ? (
102
148
  <BodyText size="md" color={labelColor} style={styles.label}>
103
149
  {currentLabel}
104
150
  </BodyText>
@@ -121,20 +167,32 @@ const styles = StyleSheet.create(theme => ({
121
167
  },
122
168
  },
123
169
  },
124
- stars: {
170
+ items: {
125
171
  flexDirection: 'row',
126
172
  gap: theme.components.rating.gap,
127
173
  },
128
- star: {
174
+ starItem: {
129
175
  width: STAR_CONTAINER_SIZE,
130
176
  height: STAR_CONTAINER_SIZE,
131
177
  alignItems: 'center',
132
178
  justifyContent: 'center',
133
179
  padding: theme.components.rating.borderWidth,
134
180
  },
181
+ emojiItem: {
182
+ width: EMOJI_CONTAINER_SIZE,
183
+ height: EMOJI_CONTAINER_SIZE,
184
+ alignItems: 'center',
185
+ justifyContent: 'center',
186
+ padding: theme.components.rating.borderWidth,
187
+ },
135
188
  label: {
136
189
  textAlign: 'center',
137
190
  },
191
+ emojiLabels: {
192
+ flexDirection: 'row',
193
+ justifyContent: 'space-between',
194
+ width: '100%',
195
+ },
138
196
  }));
139
197
 
140
198
  export default Rating;
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { EMOJI_LIST } from './Rating.utils';
3
+
4
+ describe('EMOJI_LIST', () => {
5
+ it('has exactly 5 emojis mapped to values 1-5', () => {
6
+ expect(EMOJI_LIST).toHaveLength(5);
7
+ expect(EMOJI_LIST.map(e => e.value)).toEqual([1, 2, 3, 4, 5]);
8
+ });
9
+
10
+ it('each entry has an accessibility label', () => {
11
+ for (const entry of EMOJI_LIST) {
12
+ expect(entry.accessibilityLabel).toBeTruthy();
13
+ }
14
+ });
15
+ });
@@ -0,0 +1,14 @@
1
+ import type { RatingValue } from './Rating.props';
2
+
3
+ export interface EmojiEntry {
4
+ value: Exclude<RatingValue, 0>;
5
+ accessibilityLabel: string;
6
+ }
7
+
8
+ export const EMOJI_LIST: EmojiEntry[] = [
9
+ { value: 1, accessibilityLabel: 'Very dissatisfied' },
10
+ { value: 2, accessibilityLabel: 'Dissatisfied' },
11
+ { value: 3, accessibilityLabel: 'Neutral' },
12
+ { value: 4, accessibilityLabel: 'Satisfied' },
13
+ { value: 5, accessibilityLabel: 'Very satisfied' },
14
+ ];
@@ -0,0 +1,28 @@
1
+ import BeamingFace from '@utilitywarehouse/hearth-svg-assets/lib/beaming-face.svg';
2
+ import BeamingFaceGrey from '@utilitywarehouse/hearth-svg-assets/lib/beaming-face-grey.svg';
3
+ import DissapointedFace from '@utilitywarehouse/hearth-svg-assets/lib/dissapointed-face.svg';
4
+ import DissapointedFaceGrey from '@utilitywarehouse/hearth-svg-assets/lib/dissapointed-face-grey.svg';
5
+ import FrowningFace from '@utilitywarehouse/hearth-svg-assets/lib/frowning-face.svg';
6
+ import FrowningFaceGrey from '@utilitywarehouse/hearth-svg-assets/lib/frowning-face-grey.svg';
7
+ import NeutralFace from '@utilitywarehouse/hearth-svg-assets/lib/neutral-face.svg';
8
+ import NeutralFaceGrey from '@utilitywarehouse/hearth-svg-assets/lib/neutral-face-grey.svg';
9
+ import SlightlySmilingFace from '@utilitywarehouse/hearth-svg-assets/lib/slightly-smiling-face.svg';
10
+ import SlightlySmilingFaceGrey from '@utilitywarehouse/hearth-svg-assets/lib/slightly-smiling-face-grey.svg';
11
+ import type { FC } from 'react';
12
+ import type { SvgProps } from 'react-native-svg';
13
+
14
+ const EMOJI_ASSETS: Record<1 | 2 | 3 | 4 | 5, { color: FC<SvgProps>; grey: FC<SvgProps> }> = {
15
+ 1: { color: DissapointedFace, grey: DissapointedFaceGrey },
16
+ 2: { color: FrowningFace, grey: FrowningFaceGrey },
17
+ 3: { color: NeutralFace, grey: NeutralFaceGrey },
18
+ 4: { color: SlightlySmilingFace, grey: SlightlySmilingFaceGrey },
19
+ 5: { color: BeamingFace, grey: BeamingFaceGrey },
20
+ };
21
+
22
+ export const getEmojiSvg = (
23
+ value: 1 | 2 | 3 | 4 | 5,
24
+ grayscale: boolean
25
+ ): FC<SvgProps> => {
26
+ const assets = EMOJI_ASSETS[value];
27
+ return grayscale ? assets.grey : assets.color;
28
+ };
@@ -1,2 +1,2 @@
1
1
  export { default as Rating } from './Rating';
2
- export type { RatingLabels, RatingProps, RatingValue } from './Rating.props';
2
+ export type { RatingLabels, RatingProps, RatingValue, RatingVariant } from './Rating.props';
@@ -1,13 +1,12 @@
1
+ import { createPressable } from '@gluestack-ui/pressable';
1
2
  import { useCallback, useRef } from 'react';
2
3
  import { Platform, Pressable, View } from 'react-native';
3
4
  import { StyleSheet } from 'react-native-unistyles';
5
+ import { BodyText } from '../BodyText';
4
6
  import { Icon } from '../Icon';
5
7
  import type TabProps from './Tab.props';
6
8
  import { useTabsContext } from './Tabs.context';
7
9
 
8
- import { createPressable } from '@gluestack-ui/pressable';
9
- import { BodyText } from '../BodyText';
10
-
11
10
  const Tab = ({
12
11
  value,
13
12
  children,
package/src/vite-env.d.ts CHANGED
@@ -10,3 +10,10 @@ declare module '*.svg' {
10
10
  const content: React.FC<SvgProps>;
11
11
  export default content;
12
12
  }
13
+
14
+ declare module '@utilitywarehouse/hearth-svg-assets/lib/*.svg' {
15
+ import React from 'react';
16
+ import { SvgProps } from 'react-native-svg';
17
+ const content: React.FC<SvgProps>;
18
+ export default content;
19
+ }
package/tsconfig.json CHANGED
@@ -10,6 +10,10 @@
10
10
  ],
11
11
  "compilerOptions": {
12
12
  "baseUrl": ".",
13
+ "paths": {
14
+ "react-native": ["./node_modules/react-native"],
15
+ "react-native/*": ["./node_modules/react-native/*"]
16
+ },
13
17
  "ignoreDeprecations": "5.0",
14
18
  "noEmit": false,
15
19
  "declaration": true,
package/vitest.config.js CHANGED
@@ -1,8 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
-
4
3
  import { defineConfig } from 'vitest/config';
5
-
6
4
  import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
7
5
 
8
6
  const dirname =