@utilitywarehouse/hearth-react-native 0.32.3 → 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 (37) hide show
  1. package/.storybook/main.ts +10 -0
  2. package/.turbo/turbo-lint$colon$fix.log +12 -12
  3. package/CHANGELOG.md +30 -0
  4. package/build/components/Button/Button.d.ts +5 -5
  5. package/build/components/Carousel/CarouselControlItem.d.ts +1 -1
  6. package/build/components/FormField/FormField.d.ts +1 -1
  7. package/build/components/Input/Input.d.ts +4 -4
  8. package/build/components/List/ListAction/ListAction.d.ts +1 -1
  9. package/build/components/Menu/MenuItem.d.ts +1 -1
  10. package/build/components/PillGroup/Pill.d.ts +1 -1
  11. package/build/components/RadioCard/RadioCard.d.ts +7 -7
  12. package/build/components/Rating/Rating.d.ts +1 -1
  13. package/build/components/Rating/Rating.js +40 -8
  14. package/build/components/Rating/Rating.props.d.ts +10 -2
  15. package/build/components/Rating/Rating.utils.d.ts +6 -0
  16. package/build/components/Rating/Rating.utils.js +7 -0
  17. package/build/components/Rating/Rating.utils.test.d.ts +1 -0
  18. package/build/components/Rating/Rating.utils.test.js +13 -0
  19. package/build/components/Rating/RatingEmoji.d.ts +3 -0
  20. package/build/components/Rating/RatingEmoji.js +21 -0
  21. package/build/components/Rating/index.d.ts +1 -1
  22. package/build/components/SegmentedControl/SegmentedControlOption.d.ts +1 -1
  23. package/build/components/Tabs/Tab.d.ts +1 -1
  24. package/build/components/Textarea/Textarea.d.ts +2 -2
  25. package/docs/changelog.mdx +30 -0
  26. package/package.json +9 -8
  27. package/src/components/Rating/Rating.docs.mdx +44 -5
  28. package/src/components/Rating/Rating.figma.tsx +19 -0
  29. package/src/components/Rating/Rating.props.ts +8 -2
  30. package/src/components/Rating/Rating.stories.tsx +17 -0
  31. package/src/components/Rating/Rating.tsx +86 -28
  32. package/src/components/Rating/Rating.utils.test.ts +15 -0
  33. package/src/components/Rating/Rating.utils.ts +14 -0
  34. package/src/components/Rating/RatingEmoji.tsx +28 -0
  35. package/src/components/Rating/index.ts +1 -1
  36. package/src/vite-env.d.ts +7 -0
  37. package/vitest.config.js +0 -2
@@ -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
  };
@@ -42,15 +42,15 @@ $ TIMING=1 eslint --fix .
42
42
 
43
43
  ✖ 20 problems (0 errors, 20 warnings)
44
44
 
45
- Rule | Time (ms) | Relative
46
- :---------------------------------|----------:|--------:
47
- @typescript-eslint/no-unused-vars | 1235.710 | 58.8%
48
- react-hooks/rules-of-hooks | 84.526 | 4.0%
49
- react-hooks/exhaustive-deps | 83.725 | 4.0%
50
- no-global-assign | 63.566 | 3.0%
51
- no-useless-escape | 42.579 | 2.0%
52
- no-unexpected-multiline | 40.934 | 1.9%
53
- no-fallthrough | 40.545 | 1.9%
54
- @typescript-eslint/ban-ts-comment | 37.277 | 1.8%
55
- no-regex-spaces | 35.268 | 1.7%
56
- no-misleading-character-class | 28.595 | 1.4%
45
+ Rule | Time (ms) | Relative
46
+ :----------------------------------------|----------:|--------:
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,35 @@
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
+
3
33
  ## 0.32.3
4
34
 
5
35
  ### Patch Changes
@@ -1,15 +1,15 @@
1
1
  import type { ButtonProps } from './Button.props';
2
- export declare const ButtonText: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native/types").TextProps> & import("react-native/types").TextProps>;
2
+ export declare const ButtonText: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native").TextProps> & import("react-native").TextProps>;
3
3
  export declare const ButtonSpinner: import("react").ForwardRefExoticComponent<Omit<Omit<import("..").SpinnerProps, "size">, "ref"> & import("react").RefAttributes<Omit<import("..").SpinnerProps, "size">>>;
4
4
  export declare const ButtonIcon: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("..").IconProps> & import("..").IconProps>;
5
- export declare const ButtonGroupComponent: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native/types").ViewProps & {
6
- flexDirection?: import("react-native/types").ViewStyle["flexDirection"];
5
+ export declare const ButtonGroupComponent: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native").ViewProps & {
6
+ flexDirection?: import("react-native").ViewStyle["flexDirection"];
7
7
  reversed?: boolean;
8
8
  attached?: boolean;
9
9
  space?: import("../../types").SpacingValues;
10
10
  spacing?: import("../../types").SpacingValues;
11
- }> & import("react-native/types").ViewProps & {
12
- flexDirection?: import("react-native/types").ViewStyle["flexDirection"];
11
+ }> & import("react-native").ViewProps & {
12
+ flexDirection?: import("react-native").ViewStyle["flexDirection"];
13
13
  reversed?: boolean;
14
14
  attached?: boolean;
15
15
  space?: import("../../types").SpacingValues;
@@ -10,7 +10,7 @@ declare const CarouselControlItem: import("react").ForwardRefExoticComponent<Car
10
10
  active?: boolean;
11
11
  disabled?: boolean;
12
12
  };
13
- } & Omit<import("react-native/types").PressableProps, "children"> & {
13
+ } & Omit<import("react-native").PressableProps, "children"> & {
14
14
  tabIndex?: 0 | -1 | undefined;
15
15
  } & {
16
16
  children?: import("react").ReactNode | (({ hovered, pressed, focused, focusVisible, disabled, }: {
@@ -1,6 +1,6 @@
1
1
  import { View } from 'react-native';
2
2
  import FormFieldProps from './FormField.props';
3
- export declare const FormFieldComponent: import("@gluestack-ui/form-control/lib/types").IFormControlComponentType<import("react-native/types").ViewProps, Omit<import("../Helper/Helper.props").default, "validationStatus">, Omit<import("../Helper/Helper.props").default, "validationStatus">, Omit<import("..").IconProps, "as">, unknown, Omit<import("../Label/Label.props").default, "disabled">, unknown, Omit<import("../Helper/Helper.props").default, "validationStatus">, import("../BodyText").BodyTextProps>;
3
+ export declare const FormFieldComponent: import("@gluestack-ui/form-control/lib/types").IFormControlComponentType<import("react-native").ViewProps, Omit<import("../Helper/Helper.props").default, "validationStatus">, Omit<import("../Helper/Helper.props").default, "validationStatus">, Omit<import("..").IconProps, "as">, unknown, Omit<import("../Label/Label.props").default, "disabled">, unknown, Omit<import("../Helper/Helper.props").default, "validationStatus">, import("../BodyText").BodyTextProps>;
4
4
  export declare const FormFieldLabel: import("react").ForwardRefExoticComponent<import("react").RefAttributes<unknown>> & {
5
5
  Text: import("react").ForwardRefExoticComponent<Omit<Omit<import("../Label/Label.props").default, "disabled">, "ref"> & import("react").RefAttributes<Omit<import("../Label/Label.props").default, "disabled">>>;
6
6
  };
@@ -9,13 +9,13 @@ export declare const InputComponent: import("@gluestack-ui/input/lib/typescript/
9
9
  };
10
10
  }, import("..").IconProps & {
11
11
  as?: ComponentType;
12
- }, import("react-native/types").ViewProps, import("react-native/types").TextInputProps & {
12
+ }, import("react-native").ViewProps, import("react-native").TextInputProps & {
13
13
  inBottomSheet?: boolean;
14
14
  } & import("react").RefAttributes<TextInput>>;
15
- export declare const InputSlot: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native/types").ViewProps> & import("react-native/types").ViewProps & import("@gluestack-ui/input/lib/typescript/types").IInputSlotProps>;
16
- export declare const InputField: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native/types").TextInputProps & {
15
+ export declare const InputSlot: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native").ViewProps> & import("react-native").ViewProps & import("@gluestack-ui/input/lib/typescript/types").IInputSlotProps>;
16
+ export declare const InputField: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native").TextInputProps & {
17
17
  inBottomSheet?: boolean;
18
- } & import("react").RefAttributes<TextInput>> & Omit<import("react-native/types").TextInputProps & {
18
+ } & import("react").RefAttributes<TextInput>> & Omit<import("react-native").TextInputProps & {
19
19
  inBottomSheet?: boolean;
20
20
  } & import("react").RefAttributes<TextInput>, "ref"> & import("@gluestack-ui/input/lib/typescript/types").IInputProps>;
21
21
  export declare const InputIcon: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("..").IconProps & {
@@ -4,7 +4,7 @@ declare const ListAction: import("react").ForwardRefExoticComponent<ListActionPr
4
4
  active?: boolean;
5
5
  disabled?: boolean;
6
6
  };
7
- } & Omit<import("react-native/types").PressableProps, "children"> & {
7
+ } & Omit<import("react-native").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 MenuItem: import("react").ForwardRefExoticComponent<MenuItemProps
4
4
  active?: boolean;
5
5
  disabled?: boolean;
6
6
  };
7
- } & Omit<import("react-native/types").PressableProps, "children"> & {
7
+ } & Omit<import("react-native").PressableProps, "children"> & {
8
8
  tabIndex?: 0 | -1 | undefined;
9
9
  } & {
10
10
  children?: import("react").ReactNode | (({ hovered, pressed, focused, focusVisible, disabled, }: {
@@ -3,7 +3,7 @@ export declare const Pill: import("react").ForwardRefExoticComponent<PillProps &
3
3
  states?: {
4
4
  active?: boolean;
5
5
  };
6
- } & Omit<import("react-native/types").PressableProps, "children"> & {
6
+ } & Omit<import("react-native").PressableProps, "children"> & {
7
7
  tabIndex?: 0 | -1 | undefined;
8
8
  } & {
9
9
  children?: import("react").ReactNode | (({ hovered, pressed, focused, focusVisible, disabled, }: {
@@ -5,25 +5,25 @@ declare const RadioCardGroup: import("react").ForwardRefExoticComponent<import("
5
5
  onChange?: (value: string) => void;
6
6
  gap?: keyof typeof import("../../tokens").space;
7
7
  ref?: import("react").Ref<View>;
8
- } & import("react-native/types").ViewProps & {
8
+ } & import("react-native").ViewProps & {
9
9
  columns?: never;
10
- flexDirection?: import("react-native/types").ViewStyle["flexDirection"];
11
- flexWrap?: import("react-native/types").ViewStyle["flexWrap"];
12
- alignItems?: import("react-native/types").ViewStyle["alignItems"];
13
- justifyContent?: import("react-native/types").ViewStyle["justifyContent"];
10
+ flexDirection?: import("react-native").ViewStyle["flexDirection"];
11
+ flexWrap?: import("react-native").ViewStyle["flexWrap"];
12
+ alignItems?: import("react-native").ViewStyle["alignItems"];
13
+ justifyContent?: import("react-native").ViewStyle["justifyContent"];
14
14
  }, "ref"> | Omit<{
15
15
  value?: string;
16
16
  onChange?: (value: string) => void;
17
17
  gap?: keyof typeof import("../../tokens").space;
18
18
  ref?: import("react").Ref<View>;
19
- } & import("react-native/types").ViewProps & {
19
+ } & import("react-native").ViewProps & {
20
20
  columns: import("..").GridProps["columns"];
21
21
  flexDirection?: never;
22
22
  flexWrap?: never;
23
23
  alignItems?: never;
24
24
  justifyContent?: never;
25
25
  }, "ref">) & import("@gluestack-ui/radio/lib/typescript/types").IRadioGroupProps)>;
26
- declare const RadioCardIndicator: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native/types").ViewProps> & import("react-native/types").ViewProps>;
26
+ declare const RadioCardIndicator: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("react-native").ViewProps> & import("react-native").ViewProps>;
27
27
  declare const RadioCardIcon: import("react").ForwardRefExoticComponent<import("react").RefAttributes<import("..").IconProps> & import("..").IconProps & {
28
28
  forceMount?: boolean;
29
29
  }>;
@@ -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';
@@ -4,7 +4,7 @@ declare const SegmentedControlOption: import("react").ForwardRefExoticComponent<
4
4
  active?: boolean;
5
5
  disabled?: boolean;
6
6
  };
7
- } & Omit<import("react-native/types").PressableProps, "children"> & {
7
+ } & Omit<import("react-native").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 PressableTab: import("react").ForwardRefExoticComponent<TabProps &
4
4
  active?: boolean;
5
5
  disabled?: boolean;
6
6
  };
7
- } & Omit<import("react-native/types").PressableProps, "children"> & {
7
+ } & Omit<import("react-native").PressableProps, "children"> & {
8
8
  tabIndex?: 0 | -1 | undefined;
9
9
  } & {
10
10
  children?: import("react").ReactNode | (({ hovered, pressed, focused, focusVisible, disabled, }: {
@@ -5,7 +5,7 @@ export declare const TextareaComponent: import("@gluestack-ui/textarea/lib/types
5
5
  disabled?: boolean;
6
6
  readonly?: boolean;
7
7
  };
8
- }, import("react-native/types").TextInputProps>;
9
- export declare const TextareaField: import("react").ForwardRefExoticComponent<import("react-native/types").TextInputProps & import("react").RefAttributes<import("react-native/types").TextInputProps> & import("@gluestack-ui/textarea/lib/typescript/types").IInputProps>;
8
+ }, import("react-native").TextInputProps>;
9
+ export declare const TextareaField: import("react").ForwardRefExoticComponent<import("react-native").TextInputProps & import("react").RefAttributes<import("react-native").TextInputProps> & import("@gluestack-ui/textarea/lib/typescript/types").IInputProps>;
10
10
  declare const Textarea: ({ validationStatus, children, resizable, defaultHeight, disabled, focused, readonly, label, labelVariant, helperText, validText, invalidText, required, helperIcon, onLayout, ...props }: TextareaProps) => import("react/jsx-runtime").JSX.Element;
11
11
  export default Textarea;
@@ -9,6 +9,36 @@ 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
+
12
42
  ## 0.32.3
13
43
 
14
44
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@utilitywarehouse/hearth-react-native",
3
- "version": "0.32.3",
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",
@@ -9,10 +9,10 @@
9
9
  "sideEffects": false,
10
10
  "dependencies": {
11
11
  "@gluestack-ui/accordion": "1.0.7",
12
- "@gluestack-ui/form-control": "0.1.19",
13
12
  "@gluestack-ui/alert": "0.1.15",
14
13
  "@gluestack-ui/button": "1.0.7",
15
14
  "@gluestack-ui/checkbox": "0.1.32",
15
+ "@gluestack-ui/form-control": "0.1.19",
16
16
  "@gluestack-ui/icon": "0.1.22",
17
17
  "@gluestack-ui/input": "0.1.31",
18
18
  "@gluestack-ui/link": "0.1.22",
@@ -30,6 +30,7 @@
30
30
  "@chromatic-com/storybook": "^4.1.3",
31
31
  "@figma/code-connect": "^1.3.12",
32
32
  "@gorhom/bottom-sheet": "5.2.6",
33
+ "@react-stately/utils": "^3.12.0",
33
34
  "@storybook/addon-a11y": "^10.2.1",
34
35
  "@storybook/addon-docs": "^10.2.1",
35
36
  "@storybook/addon-vitest": "^10.2.1",
@@ -42,7 +43,7 @@
42
43
  "@vitest/coverage-v8": "^3.2.4",
43
44
  "chromatic": "^13.3.0",
44
45
  "globals": "^15.15.0",
45
- "playwright": "^1.55.1",
46
+ "playwright": "^1.59.1",
46
47
  "prismjs": "^1.30.0",
47
48
  "react": "^19.1.0",
48
49
  "react-dom": "^19.1.0",
@@ -63,9 +64,9 @@
63
64
  "vite-plugin-svgr": "^4.5.0",
64
65
  "vitest": "^3.2.4",
65
66
  "@utilitywarehouse/hearth-fonts": "^0.0.4",
66
- "@utilitywarehouse/hearth-react-icons": "^0.8.1",
67
+ "@utilitywarehouse/hearth-react-icons": "^0.8.2",
67
68
  "@utilitywarehouse/hearth-react-native-icons": "^0.8.1",
68
- "@utilitywarehouse/hearth-svg-assets": "^0.6.1",
69
+ "@utilitywarehouse/hearth-svg-assets": "^0.6.2",
69
70
  "@utilitywarehouse/hearth-tokens": "^0.2.4"
70
71
  },
71
72
  "peerDependencies": {
@@ -93,10 +94,10 @@
93
94
  "figma:publish": "figma connect publish",
94
95
  "test": "vitest run --config vitest.unit.config.ts",
95
96
  "test:storybook": "vitest run --project storybook",
96
- "dev": "npm run copy:changelog && storybook dev -p 6006",
97
+ "dev": "storybook dev -p 6006",
97
98
  "dev:docs": "storybook dev -p 6002 --no-open",
98
- "build:storybook": "npm run copy:changelog && storybook build",
99
- "build:storybook:docs": "npm run copy:changelog && storybook build --docs",
99
+ "build:storybook": "storybook build",
100
+ "build:storybook:docs": "storybook build --docs",
100
101
  "chromatic": "npx chromatic --project-token=chpt_cce0fb1ebd95d2a --build-script-name build:storybook"
101
102
  }
102
103
  }
@@ -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';
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/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 =