@butternutbox/pawprint-native 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +15 -15
- package/CHANGELOG.md +30 -0
- package/COMPONENT_GUIDELINES.md +111 -4
- package/dist/index.cjs +12413 -1459
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1111 -13
- package/dist/index.d.ts +1111 -13
- package/dist/index.js +12365 -1457
- package/dist/index.js.map +1 -1
- package/package.json +29 -11
- package/src/__mocks__/asset-stub.ts +1 -0
- package/src/__mocks__/emotion-native.tsx +18 -0
- package/src/__mocks__/react-native-gesture-handler.tsx +41 -0
- package/src/__mocks__/react-native-reanimated.tsx +79 -0
- package/src/__mocks__/react-native-safe-area-context.tsx +6 -0
- package/src/__mocks__/react-native-svg.tsx +27 -0
- package/src/__mocks__/react-native-worklets.tsx +11 -0
- package/src/__mocks__/react-native.tsx +338 -0
- package/src/__mocks__/rn-primitives/avatar.tsx +24 -0
- package/src/__mocks__/rn-primitives/checkbox.tsx +19 -0
- package/src/__mocks__/rn-primitives/select.tsx +116 -0
- package/src/__mocks__/rn-primitives/slider.tsx +40 -0
- package/src/__mocks__/rn-primitives/slot.tsx +30 -0
- package/src/__mocks__/rn-primitives/switch.tsx +24 -0
- package/src/__mocks__/rn-primitives/toggle.tsx +16 -0
- package/src/components/atoms/Avatar/Avatar.stories.tsx +57 -49
- package/src/components/atoms/Avatar/Avatar.test.tsx +269 -0
- package/src/components/atoms/Avatar/Avatar.tsx +68 -22
- package/src/components/atoms/Avatar/index.ts +1 -6
- package/src/components/atoms/Badge/Badge.stories.tsx +5 -29
- package/src/components/atoms/Badge/Badge.test.tsx +90 -0
- package/src/components/atoms/Button/Button.test.tsx +123 -0
- package/src/components/atoms/Button/Button.tsx +1 -1
- package/src/components/atoms/CarouselControls/CarouselControls.stories.tsx +217 -0
- package/src/components/atoms/CarouselControls/CarouselControls.tsx +127 -0
- package/src/components/atoms/CarouselControls/index.ts +2 -0
- package/src/components/atoms/Hint/Hint.test.tsx +36 -0
- package/src/components/atoms/Icon/Icon.test.tsx +98 -0
- package/src/components/atoms/Icon/Icon.tsx +5 -1
- package/src/components/atoms/IconButton/IconButton.test.tsx +101 -0
- package/src/components/atoms/Illustration/Illustration.stories.tsx +2 -2
- package/src/components/atoms/Illustration/Illustration.test.tsx +55 -0
- package/src/components/atoms/Illustration/Illustration.tsx +3 -3
- package/src/components/atoms/Input/Input.stories.tsx +129 -86
- package/src/components/atoms/Input/Input.test.tsx +306 -0
- package/src/components/atoms/Input/Input.tsx +9 -1
- package/src/components/atoms/Input/InputField.tsx +226 -74
- package/src/components/atoms/Link/Link.test.tsx +89 -0
- package/src/components/atoms/Link/Link.tsx +7 -6
- package/src/components/atoms/Logo/Logo.registry.ts +30 -5
- package/src/components/atoms/Logo/Logo.stories.tsx +108 -0
- package/src/components/atoms/Logo/Logo.test.tsx +56 -0
- package/src/components/atoms/Logo/assets/BCorp.tsx +113 -0
- package/src/components/atoms/Logo/assets/ButternutFavicon.tsx +33 -0
- package/src/components/atoms/Logo/assets/ButternutPrimary.tsx +294 -0
- package/src/components/atoms/Logo/assets/ButternutTabbedBottom.tsx +294 -0
- package/src/components/atoms/Logo/assets/ButternutTabbedTop.tsx +294 -0
- package/src/components/atoms/Logo/assets/ButternutWordmark.tsx +274 -0
- package/src/components/atoms/Logo/assets/PsiBufetFavicon.tsx +45 -0
- package/src/components/atoms/Logo/assets/PsiBufetPrimary.tsx +218 -0
- package/src/components/atoms/Logo/assets/PsiBufetTabbedBottom.tsx +218 -0
- package/src/components/atoms/Logo/assets/PsiBufetTabbedTop.tsx +218 -0
- package/src/components/atoms/Logo/assets/PsiBufetWordmark.tsx +195 -0
- package/src/components/atoms/Logo/assets/index.ts +11 -0
- package/src/components/atoms/NumberInput/NumberInput.stories.tsx +183 -0
- package/src/components/atoms/NumberInput/NumberInput.test.tsx +261 -0
- package/src/components/atoms/NumberInput/NumberInput.tsx +129 -0
- package/src/components/atoms/NumberInput/NumberInputField.tsx +77 -0
- package/src/components/atoms/NumberInput/index.ts +4 -0
- package/src/components/atoms/Spinner/Spinner.test.tsx +46 -0
- package/src/components/atoms/Spinner/Spinner.tsx +14 -5
- package/src/components/atoms/Switch/Switch.test.tsx +92 -0
- package/src/components/atoms/Switch/Switch.tsx +28 -17
- package/src/components/atoms/Tag/Tag.test.tsx +70 -0
- package/src/components/atoms/TextArea/TextArea.stories.tsx +303 -0
- package/src/components/atoms/TextArea/TextArea.test.tsx +416 -0
- package/src/components/atoms/TextArea/TextArea.tsx +171 -0
- package/src/components/atoms/TextArea/TextAreaField.tsx +304 -0
- package/src/components/atoms/TextArea/TextAreaLabel.tsx +103 -0
- package/src/components/atoms/TextArea/index.ts +6 -0
- package/src/components/atoms/Typography/Typography.test.tsx +94 -0
- package/src/components/atoms/index.ts +3 -0
- package/src/components/molecules/Accordion/Accordion.stories.tsx +177 -0
- package/src/components/molecules/Accordion/Accordion.test.tsx +185 -0
- package/src/components/molecules/Accordion/Accordion.tsx +284 -0
- package/src/components/molecules/Accordion/index.ts +6 -0
- package/src/components/molecules/Animated/Animated.stories.tsx +254 -0
- package/src/components/molecules/Animated/Animated.tsx +283 -0
- package/src/components/molecules/Animated/index.ts +10 -0
- package/src/components/molecules/ButtonDock/ButtonDock.stories.tsx +44 -25
- package/src/components/molecules/ButtonDock/ButtonDock.test.tsx +83 -0
- package/src/components/molecules/ButtonDock/ButtonDock.tsx +16 -13
- package/src/components/molecules/ButtonGroup/ButtonGroup.stories.tsx +48 -29
- package/src/components/molecules/ButtonGroup/ButtonGroup.test.tsx +73 -0
- package/src/components/molecules/ButtonGroup/ButtonGroup.tsx +25 -3
- package/src/components/molecules/Checkbox/Checkbox.stories.tsx +72 -0
- package/src/components/molecules/Checkbox/Checkbox.test.tsx +117 -0
- package/src/components/molecules/Checkbox/Checkbox.tsx +101 -95
- package/src/components/molecules/CopyField/CopyField.stories.tsx +313 -0
- package/src/components/molecules/CopyField/CopyField.test.tsx +431 -0
- package/src/components/molecules/CopyField/CopyField.tsx +156 -0
- package/src/components/molecules/CopyField/CopyFieldInput.tsx +127 -0
- package/src/components/molecules/CopyField/hooks/index.ts +1 -0
- package/src/components/molecules/CopyField/hooks/useCopyField.ts +25 -0
- package/src/components/molecules/CopyField/index.ts +4 -0
- package/src/components/molecules/DatePicker/DatePicker.stories.tsx +298 -0
- package/src/components/molecules/DatePicker/DatePicker.test.tsx +201 -0
- package/src/components/molecules/DatePicker/DatePicker.tsx +590 -0
- package/src/components/molecules/DatePicker/index.ts +2 -0
- package/src/components/molecules/Drawer/Drawer.stories.tsx +285 -0
- package/src/components/molecules/Drawer/Drawer.test.tsx +180 -0
- package/src/components/molecules/Drawer/Drawer.tsx +187 -0
- package/src/components/molecules/Drawer/DrawerBody.tsx +80 -0
- package/src/components/molecules/Drawer/DrawerClose.tsx +76 -0
- package/src/components/molecules/Drawer/DrawerContent.tsx +339 -0
- package/src/components/molecules/Drawer/DrawerContext.ts +19 -0
- package/src/components/molecules/Drawer/DrawerDescription.tsx +31 -0
- package/src/components/molecules/Drawer/DrawerDragContext.ts +11 -0
- package/src/components/molecules/Drawer/DrawerFooter.tsx +49 -0
- package/src/components/molecules/Drawer/DrawerFooterContext.ts +6 -0
- package/src/components/molecules/Drawer/DrawerGrabber.tsx +62 -0
- package/src/components/molecules/Drawer/DrawerHeader.tsx +244 -0
- package/src/components/molecules/Drawer/DrawerHeaderContext.ts +13 -0
- package/src/components/molecules/Drawer/DrawerOverlay.tsx +53 -0
- package/src/components/molecules/Drawer/DrawerTitle.tsx +32 -0
- package/src/components/molecules/Drawer/index.ts +12 -0
- package/src/components/molecules/FilterTab/FilterTab.stories.tsx +210 -0
- package/src/components/molecules/FilterTab/FilterTab.tsx +310 -0
- package/src/components/molecules/FilterTab/index.ts +2 -0
- package/src/components/molecules/MessageCard/MessageCard.stories.tsx +169 -0
- package/src/components/molecules/MessageCard/MessageCard.tsx +362 -0
- package/src/components/molecules/MessageCard/index.ts +10 -0
- package/src/components/molecules/Notification/Notification.stories.tsx +219 -0
- package/src/components/molecules/Notification/Notification.tsx +426 -0
- package/src/components/molecules/Notification/index.ts +2 -0
- package/src/components/molecules/NumberField/NumberField.stories.tsx +231 -0
- package/src/components/molecules/NumberField/NumberField.tsx +186 -0
- package/src/components/molecules/NumberField/NumberFieldInput.tsx +287 -0
- package/src/components/molecules/NumberField/index.ts +2 -0
- package/src/components/molecules/PasswordField/PasswordField.stories.tsx +362 -0
- package/src/components/molecules/PasswordField/PasswordField.test.tsx +369 -0
- package/src/components/molecules/PasswordField/PasswordField.tsx +194 -0
- package/src/components/molecules/PasswordField/PasswordFieldError.tsx +53 -0
- package/src/components/molecules/PasswordField/PasswordFieldInput.tsx +73 -0
- package/src/components/molecules/PasswordField/PasswordFieldRequirements.tsx +95 -0
- package/src/components/molecules/PasswordField/hooks/index.ts +2 -0
- package/src/components/molecules/PasswordField/hooks/usePasswordField.ts +113 -0
- package/src/components/molecules/PasswordField/index.ts +10 -0
- package/src/components/molecules/PictureSelector/PictureSelector.stories.tsx +204 -0
- package/src/components/molecules/PictureSelector/PictureSelector.tsx +335 -0
- package/src/components/molecules/PictureSelector/index.ts +5 -0
- package/src/components/molecules/Progress/Progress.stories.tsx +145 -0
- package/src/components/molecules/Progress/Progress.tsx +184 -0
- package/src/components/molecules/Progress/index.ts +2 -0
- package/src/components/molecules/Radio/Radio.test.tsx +104 -0
- package/src/components/molecules/Radio/Radio.tsx +1 -2
- package/src/components/molecules/SearchField/SearchField.stories.tsx +242 -0
- package/src/components/molecules/SearchField/SearchField.test.tsx +318 -0
- package/src/components/molecules/SearchField/SearchField.tsx +143 -0
- package/src/components/molecules/SearchField/SearchFieldInput.tsx +63 -0
- package/src/components/molecules/SearchField/hooks/index.ts +1 -0
- package/src/components/molecules/SearchField/hooks/useSearchField.ts +56 -0
- package/src/components/molecules/SearchField/index.ts +4 -0
- package/src/components/molecules/SegmentedControl/SegmentedControl.stories.tsx +31 -8
- package/src/components/molecules/SegmentedControl/SegmentedControl.test.tsx +141 -0
- package/src/components/molecules/SegmentedControl/SegmentedControl.tsx +237 -23
- package/src/components/molecules/SelectField/SelectField.stories.tsx +320 -0
- package/src/components/molecules/SelectField/SelectField.test.tsx +254 -0
- package/src/components/molecules/SelectField/SelectField.tsx +236 -0
- package/src/components/molecules/SelectField/SelectFieldContent.tsx +85 -0
- package/src/components/molecules/SelectField/SelectFieldItem.tsx +133 -0
- package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +170 -0
- package/src/components/molecules/SelectField/SelectFieldValue.tsx +31 -0
- package/src/components/molecules/SelectField/hooks/index.ts +2 -0
- package/src/components/molecules/SelectField/hooks/useSelectField.ts +84 -0
- package/src/components/molecules/SelectField/index.ts +10 -0
- package/src/components/molecules/Slider/Slider.test.tsx +102 -0
- package/src/components/molecules/Slider/Slider.tsx +293 -180
- package/src/components/molecules/Tooltip/Tooltip.stories.tsx +168 -0
- package/src/components/molecules/Tooltip/Tooltip.tsx +326 -0
- package/src/components/molecules/Tooltip/index.ts +2 -0
- package/src/components/molecules/index.ts +15 -0
- package/src/test-utils.tsx +20 -0
- package/tsconfig.json +1 -1
- package/tsup.config.ts +16 -2
- package/vitest.config.ts +114 -0
- package/vitest.setup.ts +16 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect, useCallback } from "react"
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
TextInput,
|
|
5
|
+
TextInputProps,
|
|
6
|
+
Animated,
|
|
7
|
+
Easing,
|
|
8
|
+
Pressable
|
|
9
|
+
} from "react-native"
|
|
10
|
+
import styled from "@emotion/native"
|
|
11
|
+
import { useTheme } from "@emotion/react"
|
|
12
|
+
import {
|
|
13
|
+
Error as ErrorIcon,
|
|
14
|
+
CheckCircle as SuccessIcon
|
|
15
|
+
} from "@butternutbox/pawprint-icons/core"
|
|
16
|
+
import { Icon } from "../Icon"
|
|
17
|
+
import type { InputState } from "../Input/InputField"
|
|
18
|
+
|
|
19
|
+
type TextAreaFieldOwnProps = {
|
|
20
|
+
leadingIcon?: React.ReactNode
|
|
21
|
+
trailingIcon?: React.ReactNode
|
|
22
|
+
state?: InputState
|
|
23
|
+
hideStateIcons?: boolean
|
|
24
|
+
rows?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type TextAreaFieldProps = TextAreaFieldOwnProps &
|
|
28
|
+
Omit<TextInputProps, keyof TextAreaFieldOwnProps>
|
|
29
|
+
|
|
30
|
+
const parseTokenValue = (value: string): number => parseFloat(value)
|
|
31
|
+
|
|
32
|
+
const StyledTextAreaWrapper = styled(Animated.View)<{
|
|
33
|
+
isDisabled: boolean
|
|
34
|
+
}>(({ theme, isDisabled }) => {
|
|
35
|
+
const { spacing, colour, borderRadius, size } = theme.tokens.components.inputs
|
|
36
|
+
const { dimensions } = theme.tokens.semantics
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
flexDirection: "column",
|
|
40
|
+
gap: parseTokenValue(spacing.field.gap),
|
|
41
|
+
width: "100%",
|
|
42
|
+
minWidth: parseTokenValue(size.field.minWidth),
|
|
43
|
+
minHeight: parseTokenValue(dimensions.sizing["3xl"]),
|
|
44
|
+
backgroundColor: isDisabled
|
|
45
|
+
? colour.field.background.inactive
|
|
46
|
+
: colour.field.background.default,
|
|
47
|
+
borderRadius: parseTokenValue(borderRadius.field.default),
|
|
48
|
+
position: "relative"
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const StyledTextArea = styled(TextInput)(({ theme }) => {
|
|
53
|
+
const { colour } = theme.tokens.components.inputs
|
|
54
|
+
return {
|
|
55
|
+
flex: 1,
|
|
56
|
+
minWidth: 0,
|
|
57
|
+
minHeight: 0,
|
|
58
|
+
padding: 0,
|
|
59
|
+
color: colour.field.text.default,
|
|
60
|
+
textAlignVertical: "top",
|
|
61
|
+
// Suppress the browser default focus outline on React Native Web —
|
|
62
|
+
// the focus indicator is rendered on the wrapper instead.
|
|
63
|
+
...({
|
|
64
|
+
outlineStyle: "none",
|
|
65
|
+
outlineWidth: 0,
|
|
66
|
+
outlineColor: "transparent"
|
|
67
|
+
} as object)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const StyledFocusRing = styled(View)({
|
|
72
|
+
position: "absolute",
|
|
73
|
+
pointerEvents: "none"
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const TextAreaContainer = styled(View)({
|
|
77
|
+
position: "relative",
|
|
78
|
+
width: "100%",
|
|
79
|
+
flex: 1,
|
|
80
|
+
flexDirection: "column"
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const IconWrapper = styled(View)<{ side: "left" | "right" }>(({ side }) => {
|
|
84
|
+
return {
|
|
85
|
+
position: "absolute",
|
|
86
|
+
top: 0,
|
|
87
|
+
[side]: 0,
|
|
88
|
+
alignItems: "center",
|
|
89
|
+
justifyContent: "flex-start",
|
|
90
|
+
flexShrink: 0,
|
|
91
|
+
zIndex: 1
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* TextAreaField component - Multi-line text input field with icons and visual states.
|
|
97
|
+
*
|
|
98
|
+
* @param {React.ReactNode} [leadingIcon] - Icon to display at the start of the textarea (top-left, should be 24px)
|
|
99
|
+
* @param {React.ReactNode} [trailingIcon] - Icon to display at the end of the textarea (top-right, overrides automatic state icons)
|
|
100
|
+
* @param {InputState} [state] - Visual state of the textarea (default, error, success)
|
|
101
|
+
* @param {boolean} [hideStateIcons] - Hide automatic error/success icons
|
|
102
|
+
* @param {number} [rows=2] - Number of visible text rows (used to calculate minHeight)
|
|
103
|
+
*/
|
|
104
|
+
export const TextAreaField = React.forwardRef<TextInput, TextAreaFieldProps>(
|
|
105
|
+
(
|
|
106
|
+
{
|
|
107
|
+
leadingIcon,
|
|
108
|
+
trailingIcon,
|
|
109
|
+
state = "default",
|
|
110
|
+
hideStateIcons,
|
|
111
|
+
rows = 2,
|
|
112
|
+
style,
|
|
113
|
+
editable,
|
|
114
|
+
onFocus,
|
|
115
|
+
onBlur,
|
|
116
|
+
...rest
|
|
117
|
+
},
|
|
118
|
+
ref
|
|
119
|
+
) => {
|
|
120
|
+
const theme = useTheme()
|
|
121
|
+
const { spacing, colour, borderWidth, borderRadius, size } =
|
|
122
|
+
theme.tokens.components.inputs
|
|
123
|
+
const { dimensions } = theme.tokens.semantics
|
|
124
|
+
const focusBorderColor = theme.tokens.semantics.colour.border.focus
|
|
125
|
+
|
|
126
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
127
|
+
|
|
128
|
+
const internalInputRef = useRef<TextInput>(null)
|
|
129
|
+
const combinedRef = useCallback(
|
|
130
|
+
(node: TextInput | null) => {
|
|
131
|
+
internalInputRef.current = node
|
|
132
|
+
if (typeof ref === "function") {
|
|
133
|
+
ref(node)
|
|
134
|
+
} else if (ref) {
|
|
135
|
+
;(ref as React.MutableRefObject<TextInput | null>).current = node
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
[ref]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const isDisabled = editable === false
|
|
142
|
+
const hasState = state === "error" || state === "success"
|
|
143
|
+
|
|
144
|
+
const baseBorderWidth = parseTokenValue(borderWidth.field.default)
|
|
145
|
+
const selectedBorderWidth = parseTokenValue(borderWidth.field.selected)
|
|
146
|
+
const focusBorderWidth = parseTokenValue(borderWidth.field.focus)
|
|
147
|
+
const fieldBorderRadius = parseTokenValue(borderRadius.field.default)
|
|
148
|
+
const baseVerticalPadding = parseTokenValue(spacing.field.verticalPadding)
|
|
149
|
+
const baseHorizontalPadding = parseTokenValue(
|
|
150
|
+
spacing.field.horizontalPadding
|
|
151
|
+
)
|
|
152
|
+
const borderDelta = selectedBorderWidth - baseBorderWidth
|
|
153
|
+
|
|
154
|
+
const borderColor =
|
|
155
|
+
state === "error"
|
|
156
|
+
? colour.field.border.error
|
|
157
|
+
: state === "success"
|
|
158
|
+
? colour.field.border.success
|
|
159
|
+
: isFocused
|
|
160
|
+
? colour.field.border.selected
|
|
161
|
+
: colour.field.border.default
|
|
162
|
+
|
|
163
|
+
const isActiveBorder = isFocused || hasState
|
|
164
|
+
const borderAnim = useRef(
|
|
165
|
+
new Animated.Value(isActiveBorder ? 1 : 0)
|
|
166
|
+
).current
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
const animation = Animated.timing(borderAnim, {
|
|
170
|
+
toValue: isActiveBorder ? 1 : 0,
|
|
171
|
+
duration: 50,
|
|
172
|
+
easing: Easing.ease,
|
|
173
|
+
useNativeDriver: false
|
|
174
|
+
})
|
|
175
|
+
animation.start()
|
|
176
|
+
return () => animation.stop()
|
|
177
|
+
}, [isActiveBorder, borderAnim])
|
|
178
|
+
|
|
179
|
+
const animatedBorderWidth = borderAnim.interpolate({
|
|
180
|
+
inputRange: [0, 1],
|
|
181
|
+
outputRange: [baseBorderWidth, selectedBorderWidth]
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const animatedPaddingVertical = borderAnim.interpolate({
|
|
185
|
+
inputRange: [0, 1],
|
|
186
|
+
outputRange: [baseVerticalPadding, baseVerticalPadding - borderDelta]
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const animatedPaddingHorizontal = borderAnim.interpolate({
|
|
190
|
+
inputRange: [0, 1],
|
|
191
|
+
outputRange: [baseHorizontalPadding, baseHorizontalPadding - borderDelta]
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const stateIconMap = {
|
|
195
|
+
error: { icon: ErrorIcon, colour: "error" as const },
|
|
196
|
+
success: { icon: SuccessIcon, colour: "success" as const }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const stateIconConfig = state !== "default" ? stateIconMap[state] : null
|
|
200
|
+
|
|
201
|
+
// Trailing icons should be 16px (sm), leading icons 24px (md)
|
|
202
|
+
const effectiveTrailingIcon =
|
|
203
|
+
trailingIcon ||
|
|
204
|
+
(!hideStateIcons && stateIconConfig ? (
|
|
205
|
+
<Icon
|
|
206
|
+
icon={stateIconConfig.icon}
|
|
207
|
+
size="sm"
|
|
208
|
+
colour={stateIconConfig.colour}
|
|
209
|
+
/>
|
|
210
|
+
) : null)
|
|
211
|
+
|
|
212
|
+
const handleFocus = (
|
|
213
|
+
e: Parameters<NonNullable<TextInputProps["onFocus"]>>[0]
|
|
214
|
+
) => {
|
|
215
|
+
setIsFocused(true)
|
|
216
|
+
onFocus?.(e)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const handleBlur = (
|
|
220
|
+
e: Parameters<NonNullable<TextInputProps["onBlur"]>>[0]
|
|
221
|
+
) => {
|
|
222
|
+
setIsFocused(false)
|
|
223
|
+
onBlur?.(e)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const handleWrapperPress = () => {
|
|
227
|
+
internalInputRef.current?.focus()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const fieldHeight = parseTokenValue(size.field.height)
|
|
231
|
+
const textAreaMinHeight = fieldHeight
|
|
232
|
+
|
|
233
|
+
// Calculate icon padding using tokens
|
|
234
|
+
const iconGap = parseTokenValue(spacing.field.gap)
|
|
235
|
+
const leadingIconSize = parseTokenValue(dimensions.sizing.xl) // 24px
|
|
236
|
+
const trailingIconSize = parseTokenValue(dimensions.sizing.md) // 16px
|
|
237
|
+
const leadingIconPadding = leadingIconSize + iconGap
|
|
238
|
+
const trailingIconPadding = trailingIconSize + iconGap
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<Pressable onPress={handleWrapperPress}>
|
|
242
|
+
<StyledTextAreaWrapper
|
|
243
|
+
isDisabled={isDisabled}
|
|
244
|
+
style={[
|
|
245
|
+
{
|
|
246
|
+
borderColor,
|
|
247
|
+
borderWidth: animatedBorderWidth,
|
|
248
|
+
paddingVertical: animatedPaddingVertical,
|
|
249
|
+
paddingHorizontal: animatedPaddingHorizontal
|
|
250
|
+
}
|
|
251
|
+
]}
|
|
252
|
+
>
|
|
253
|
+
{isFocused && !hasState && (
|
|
254
|
+
<StyledFocusRing
|
|
255
|
+
style={{
|
|
256
|
+
top: -focusBorderWidth,
|
|
257
|
+
left: -focusBorderWidth,
|
|
258
|
+
right: -focusBorderWidth,
|
|
259
|
+
bottom: -focusBorderWidth,
|
|
260
|
+
borderWidth: focusBorderWidth,
|
|
261
|
+
borderColor: focusBorderColor,
|
|
262
|
+
borderRadius: fieldBorderRadius + focusBorderWidth
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
266
|
+
<TextAreaContainer>
|
|
267
|
+
{leadingIcon && (
|
|
268
|
+
<IconWrapper side="left">{leadingIcon}</IconWrapper>
|
|
269
|
+
)}
|
|
270
|
+
{effectiveTrailingIcon && (
|
|
271
|
+
<IconWrapper side="right">{effectiveTrailingIcon}</IconWrapper>
|
|
272
|
+
)}
|
|
273
|
+
<View
|
|
274
|
+
style={{
|
|
275
|
+
flex: 1,
|
|
276
|
+
paddingLeft: leadingIcon ? leadingIconPadding : 0,
|
|
277
|
+
paddingRight: effectiveTrailingIcon ? trailingIconPadding : 0
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
<StyledTextArea
|
|
281
|
+
ref={combinedRef}
|
|
282
|
+
style={[
|
|
283
|
+
style,
|
|
284
|
+
{
|
|
285
|
+
minHeight: textAreaMinHeight
|
|
286
|
+
}
|
|
287
|
+
]}
|
|
288
|
+
placeholderTextColor={colour.field.text.placeholder}
|
|
289
|
+
editable={editable}
|
|
290
|
+
multiline
|
|
291
|
+
numberOfLines={rows}
|
|
292
|
+
onFocus={handleFocus}
|
|
293
|
+
onBlur={handleBlur}
|
|
294
|
+
{...rest}
|
|
295
|
+
/>
|
|
296
|
+
</View>
|
|
297
|
+
</TextAreaContainer>
|
|
298
|
+
</StyledTextAreaWrapper>
|
|
299
|
+
</Pressable>
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
TextAreaField.displayName = "TextArea.Field"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { View } from "react-native"
|
|
3
|
+
import styled from "@emotion/native"
|
|
4
|
+
import { useTheme } from "@emotion/react"
|
|
5
|
+
import { Typography } from "../Typography"
|
|
6
|
+
import { Icon } from "../Icon"
|
|
7
|
+
import { Error as ErrorIcon } from "@butternutbox/pawprint-icons/core"
|
|
8
|
+
import type { InputState } from "../Input/InputField"
|
|
9
|
+
|
|
10
|
+
type TextAreaLabelOwnProps = {
|
|
11
|
+
optionalText?: string
|
|
12
|
+
state?: InputState
|
|
13
|
+
children: React.ReactNode
|
|
14
|
+
maxLength?: number
|
|
15
|
+
currentLength?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type TextAreaLabelProps = TextAreaLabelOwnProps
|
|
19
|
+
|
|
20
|
+
const parseTokenValue = (value: string): number => parseFloat(value)
|
|
21
|
+
|
|
22
|
+
const StyledLabelWrapper = styled(View)({
|
|
23
|
+
flexDirection: "row",
|
|
24
|
+
alignItems: "center",
|
|
25
|
+
justifyContent: "space-between",
|
|
26
|
+
width: "100%"
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const StyledLabel = styled(View)<{ labelGap: number }>(({ labelGap }) => ({
|
|
30
|
+
flexDirection: "row",
|
|
31
|
+
alignItems: "center",
|
|
32
|
+
gap: labelGap
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
const StyledCharCountWrapper = styled(View)<{
|
|
36
|
+
charCountGap: number
|
|
37
|
+
charCountPadding: number
|
|
38
|
+
}>(({ charCountGap, charCountPadding }) => ({
|
|
39
|
+
flexDirection: "row",
|
|
40
|
+
alignItems: "center",
|
|
41
|
+
gap: charCountGap,
|
|
42
|
+
paddingLeft: charCountPadding
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
export const TextAreaLabel = React.forwardRef<View, TextAreaLabelProps>(
|
|
46
|
+
(
|
|
47
|
+
{
|
|
48
|
+
optionalText,
|
|
49
|
+
state = "default",
|
|
50
|
+
children,
|
|
51
|
+
maxLength,
|
|
52
|
+
currentLength,
|
|
53
|
+
...rest
|
|
54
|
+
},
|
|
55
|
+
ref
|
|
56
|
+
) => {
|
|
57
|
+
const theme = useTheme()
|
|
58
|
+
const { colour, text, spacing } = theme.tokens.components.inputs
|
|
59
|
+
|
|
60
|
+
const showCharacterCount =
|
|
61
|
+
maxLength !== undefined && currentLength !== undefined
|
|
62
|
+
const isOverLimit =
|
|
63
|
+
currentLength !== undefined &&
|
|
64
|
+
maxLength !== undefined &&
|
|
65
|
+
currentLength > maxLength
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<StyledLabelWrapper ref={ref} {...rest}>
|
|
69
|
+
<StyledLabel labelGap={parseTokenValue(spacing.label.gap)}>
|
|
70
|
+
<Typography
|
|
71
|
+
token={text.label}
|
|
72
|
+
color={
|
|
73
|
+
state === "error" ? colour.label.error : colour.label.default
|
|
74
|
+
}
|
|
75
|
+
>
|
|
76
|
+
{children}
|
|
77
|
+
</Typography>
|
|
78
|
+
{optionalText && (
|
|
79
|
+
<Typography token={text.optional} color={colour.label.optional}>
|
|
80
|
+
{optionalText}
|
|
81
|
+
</Typography>
|
|
82
|
+
)}
|
|
83
|
+
</StyledLabel>
|
|
84
|
+
{showCharacterCount && (
|
|
85
|
+
<StyledCharCountWrapper
|
|
86
|
+
charCountGap={parseTokenValue(spacing.field.gap)}
|
|
87
|
+
charCountPadding={parseTokenValue(spacing.description.leftPadding)}
|
|
88
|
+
>
|
|
89
|
+
{isOverLimit && <Icon icon={ErrorIcon} size="sm" colour="error" />}
|
|
90
|
+
<Typography
|
|
91
|
+
token={text.characterCount}
|
|
92
|
+
color={colour.characterCount.default}
|
|
93
|
+
>
|
|
94
|
+
{currentLength}/{maxLength}
|
|
95
|
+
</Typography>
|
|
96
|
+
</StyledCharCountWrapper>
|
|
97
|
+
)}
|
|
98
|
+
</StyledLabelWrapper>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
TextAreaLabel.displayName = "TextArea.Label"
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { TextArea } from "./TextArea"
|
|
2
|
+
export type { TextAreaProps } from "./TextArea"
|
|
3
|
+
export { TextAreaField } from "./TextAreaField"
|
|
4
|
+
export type { TextAreaFieldProps } from "./TextAreaField"
|
|
5
|
+
export { TextAreaLabel } from "./TextAreaLabel"
|
|
6
|
+
export type { TextAreaLabelProps } from "./TextAreaLabel"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { screen } from "@testing-library/react"
|
|
3
|
+
import { describe, it, expect } from "vitest"
|
|
4
|
+
import { renderWithTheme } from "../../../test-utils"
|
|
5
|
+
import { Typography } from "./Typography"
|
|
6
|
+
|
|
7
|
+
describe("Typography", () => {
|
|
8
|
+
describe("when component is rendering", () => {
|
|
9
|
+
it("renders children text", () => {
|
|
10
|
+
renderWithTheme(<Typography>Hello world</Typography>)
|
|
11
|
+
expect(screen.getByText("Hello world")).toBeInTheDocument()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("renders with default body variant", () => {
|
|
15
|
+
renderWithTheme(<Typography>Body text</Typography>)
|
|
16
|
+
expect(screen.getByText("Body text")).toBeInTheDocument()
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe("when using semantic variants", () => {
|
|
21
|
+
it("renders body variant", () => {
|
|
22
|
+
renderWithTheme(<Typography variant="body">Body</Typography>)
|
|
23
|
+
expect(screen.getByText("Body")).toBeInTheDocument()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("renders heading variant", () => {
|
|
27
|
+
renderWithTheme(<Typography variant="heading">Heading</Typography>)
|
|
28
|
+
expect(screen.getByText("Heading")).toBeInTheDocument()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("renders display variant", () => {
|
|
32
|
+
renderWithTheme(<Typography variant="display">Display</Typography>)
|
|
33
|
+
expect(screen.getByText("Display")).toBeInTheDocument()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe("when using body sizes", () => {
|
|
38
|
+
it.each(["xs", "sm", "md", "lg"] as const)(
|
|
39
|
+
"renders body size %s",
|
|
40
|
+
(size) => {
|
|
41
|
+
renderWithTheme(<Typography size={size}>Size {size}</Typography>)
|
|
42
|
+
expect(screen.getByText(`Size ${size}`)).toBeInTheDocument()
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe("when using body weights", () => {
|
|
48
|
+
it.each(["medium", "semiBold", "bold"] as const)(
|
|
49
|
+
"renders weight %s",
|
|
50
|
+
(weight) => {
|
|
51
|
+
renderWithTheme(<Typography weight={weight}>Weight</Typography>)
|
|
52
|
+
expect(screen.getByText("Weight")).toBeInTheDocument()
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe("when using token mode", () => {
|
|
58
|
+
it("renders with a token object", () => {
|
|
59
|
+
renderWithTheme(
|
|
60
|
+
<Typography
|
|
61
|
+
token={{
|
|
62
|
+
fontFamily: "IBM Plex Sans Condensed",
|
|
63
|
+
fontWeight: "500",
|
|
64
|
+
fontSize: "16",
|
|
65
|
+
lineHeight: "24"
|
|
66
|
+
}}
|
|
67
|
+
color="#000000"
|
|
68
|
+
>
|
|
69
|
+
Token text
|
|
70
|
+
</Typography>
|
|
71
|
+
)
|
|
72
|
+
expect(screen.getByText("Token text")).toBeInTheDocument()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe("when using common props", () => {
|
|
77
|
+
it("renders with noWrap", () => {
|
|
78
|
+
renderWithTheme(<Typography noWrap>Truncated</Typography>)
|
|
79
|
+
expect(screen.getByText("Truncated")).toBeInTheDocument()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("renders with textTransform", () => {
|
|
83
|
+
renderWithTheme(<Typography textTransform="uppercase">upper</Typography>)
|
|
84
|
+
expect(screen.getByText("upper")).toBeInTheDocument()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("renders with textDecoration", () => {
|
|
88
|
+
renderWithTheme(
|
|
89
|
+
<Typography textDecoration="underline">underlined</Typography>
|
|
90
|
+
)
|
|
91
|
+
expect(screen.getByText("underlined")).toBeInTheDocument()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from "./Avatar"
|
|
2
|
+
export * from "./CarouselControls"
|
|
2
3
|
export * from "./Typography"
|
|
3
4
|
export * from "./Badge"
|
|
4
5
|
export * from "./Button"
|
|
@@ -9,6 +10,8 @@ export * from "./Illustration"
|
|
|
9
10
|
export * from "./Input"
|
|
10
11
|
export * from "./Link"
|
|
11
12
|
export * from "./Logo"
|
|
13
|
+
export * from "./NumberInput"
|
|
12
14
|
export * from "./Spinner"
|
|
13
15
|
export * from "./Switch"
|
|
14
16
|
export * from "./Tag"
|
|
17
|
+
export * from "./TextArea"
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { View, StyleSheet, Text } from "react-native"
|
|
3
|
+
import { Accordion } from "./Accordion"
|
|
4
|
+
import type { AccordionProps } from "./Accordion"
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
title: "Molecules/Accordion",
|
|
8
|
+
component: Accordion,
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"A vertically stacked list of items that reveal or hide content when clicked."
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
argTypes: {
|
|
18
|
+
size: {
|
|
19
|
+
control: { type: "select" },
|
|
20
|
+
options: ["small", "large"],
|
|
21
|
+
description: "Title size variant"
|
|
22
|
+
},
|
|
23
|
+
multiple: {
|
|
24
|
+
control: { type: "boolean" },
|
|
25
|
+
description: "Allow multiple items open simultaneously"
|
|
26
|
+
},
|
|
27
|
+
disabled: {
|
|
28
|
+
control: { type: "boolean" },
|
|
29
|
+
description: "Disables all items"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const faqItems = [
|
|
35
|
+
{
|
|
36
|
+
title: "What ingredients do you use?",
|
|
37
|
+
content:
|
|
38
|
+
"We use 60% human-quality protein paired with fresh vegetables and wholesome grains."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
title: "How is the food delivered?",
|
|
42
|
+
content:
|
|
43
|
+
"Your order is fresh-frozen and delivered straight to your door in an insulated box."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
title: "Is it suitable for puppies?",
|
|
47
|
+
content:
|
|
48
|
+
"Yes! Our recipes are nutritionally complete for all life stages, including puppies over 4 months."
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
export const Small = () => (
|
|
53
|
+
<Accordion size="small">
|
|
54
|
+
{faqItems.map((item) => (
|
|
55
|
+
<Accordion.Item key={item.title} title={item.title}>
|
|
56
|
+
{item.content}
|
|
57
|
+
</Accordion.Item>
|
|
58
|
+
))}
|
|
59
|
+
</Accordion>
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
export const Large = () => (
|
|
63
|
+
<Accordion size="large">
|
|
64
|
+
{faqItems.map((item) => (
|
|
65
|
+
<Accordion.Item key={item.title} title={item.title}>
|
|
66
|
+
{item.content}
|
|
67
|
+
</Accordion.Item>
|
|
68
|
+
))}
|
|
69
|
+
</Accordion>
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
export const Multiple = () => (
|
|
73
|
+
<Accordion multiple>
|
|
74
|
+
{faqItems.map((item) => (
|
|
75
|
+
<Accordion.Item key={item.title} title={item.title}>
|
|
76
|
+
{item.content}
|
|
77
|
+
</Accordion.Item>
|
|
78
|
+
))}
|
|
79
|
+
</Accordion>
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
export const DefaultOpen = () => (
|
|
83
|
+
<Accordion defaultValue={["ingredients"]}>
|
|
84
|
+
<Accordion.Item value="ingredients" title="What ingredients do you use?">
|
|
85
|
+
We use 60% human-quality protein paired with fresh vegetables and
|
|
86
|
+
wholesome grains.
|
|
87
|
+
</Accordion.Item>
|
|
88
|
+
<Accordion.Item value="delivery" title="How is the food delivered?">
|
|
89
|
+
Your order is fresh-frozen and delivered straight to your door in an
|
|
90
|
+
insulated box.
|
|
91
|
+
</Accordion.Item>
|
|
92
|
+
<Accordion.Item value="puppies" title="Is it suitable for puppies?">
|
|
93
|
+
Yes! Our recipes are nutritionally complete for all life stages, including
|
|
94
|
+
puppies over 4 months.
|
|
95
|
+
</Accordion.Item>
|
|
96
|
+
</Accordion>
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
export const Disabled = () => (
|
|
100
|
+
<Accordion disabled>
|
|
101
|
+
{faqItems.slice(0, 2).map((item) => (
|
|
102
|
+
<Accordion.Item key={item.title} title={item.title}>
|
|
103
|
+
{item.content}
|
|
104
|
+
</Accordion.Item>
|
|
105
|
+
))}
|
|
106
|
+
</Accordion>
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
export const AllStates = () => (
|
|
110
|
+
<View style={styles.column}>
|
|
111
|
+
<View style={styles.section}>
|
|
112
|
+
<Text style={styles.label}>Default (closed)</Text>
|
|
113
|
+
<Accordion>
|
|
114
|
+
<Accordion.Item title="Accordion title">
|
|
115
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
|
116
|
+
</Accordion.Item>
|
|
117
|
+
</Accordion>
|
|
118
|
+
</View>
|
|
119
|
+
|
|
120
|
+
<View style={styles.section}>
|
|
121
|
+
<Text style={styles.label}>Open</Text>
|
|
122
|
+
<Accordion defaultValue={["open-item"]}>
|
|
123
|
+
<Accordion.Item value="open-item" title="Accordion title">
|
|
124
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
|
125
|
+
</Accordion.Item>
|
|
126
|
+
</Accordion>
|
|
127
|
+
</View>
|
|
128
|
+
|
|
129
|
+
<View style={styles.section}>
|
|
130
|
+
<Text style={styles.label}>Multiple items</Text>
|
|
131
|
+
<Accordion>
|
|
132
|
+
<Accordion.Item title="Accordion title one">
|
|
133
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
|
134
|
+
</Accordion.Item>
|
|
135
|
+
<Accordion.Item title="Accordion title two">
|
|
136
|
+
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
|
137
|
+
</Accordion.Item>
|
|
138
|
+
<Accordion.Item title="Accordion title three">
|
|
139
|
+
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
|
|
140
|
+
</Accordion.Item>
|
|
141
|
+
</Accordion>
|
|
142
|
+
</View>
|
|
143
|
+
</View>
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
export const Playground = (args: AccordionProps) => (
|
|
147
|
+
<Accordion {...args}>
|
|
148
|
+
{faqItems.map((item) => (
|
|
149
|
+
<Accordion.Item key={item.title} title={item.title}>
|
|
150
|
+
{item.content}
|
|
151
|
+
</Accordion.Item>
|
|
152
|
+
))}
|
|
153
|
+
</Accordion>
|
|
154
|
+
)
|
|
155
|
+
Playground.args = {
|
|
156
|
+
size: "small",
|
|
157
|
+
multiple: false,
|
|
158
|
+
disabled: false
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const styles = StyleSheet.create({
|
|
162
|
+
column: {
|
|
163
|
+
flexDirection: "column",
|
|
164
|
+
gap: 32
|
|
165
|
+
},
|
|
166
|
+
section: {
|
|
167
|
+
flexDirection: "column",
|
|
168
|
+
gap: 12
|
|
169
|
+
},
|
|
170
|
+
label: {
|
|
171
|
+
fontSize: 12,
|
|
172
|
+
fontWeight: "600",
|
|
173
|
+
textTransform: "uppercase",
|
|
174
|
+
letterSpacing: 1,
|
|
175
|
+
color: "#999"
|
|
176
|
+
}
|
|
177
|
+
})
|