Skip to content

Commit 987419d

Browse files
committed
feat: Add NumberStepper form component
1 parent 357fbf4 commit 987419d

File tree

9 files changed

+320
-47
lines changed

9 files changed

+320
-47
lines changed

features/@app-core/components/styled.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const ScrollView = remapProps(styled(RNScrollView), {
3636

3737
/* --- Typography ------------------------------------------------------------------------------ */
3838

39-
export const H1 = styled(RNText, 'font-bold text-2xl')
39+
export const H1 = styled(RNText, 'font-bold text-3xl')
4040
export const H2 = styled(RNText, 'font-bold text-xl')
4141
export const H3 = styled(RNText, 'font-bold text-lg')
4242

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { forwardRef, ElementRef, useState, useEffect } from 'react'
2+
import type { NativeSyntheticEvent, TextInputKeyPressEventData } from 'react-native'
3+
import { TextInput } from './TextInput.styled'
4+
import { z, schema } from '@green-stack/schemas'
5+
import { cn, View, Pressable } from '../components/styled'
6+
import { AddFilled } from '../icons/AddFilled'
7+
import { RemoveFilled } from '../icons/RemoveFilled'
8+
9+
/* --- Schema ---------------------------------------------------------------------------------- */
10+
11+
export const NumberStepperProps = schema('NumberStepperProps', {
12+
value: z.number().default(0),
13+
min: z.number().default(0),
14+
max: z.number().optional(),
15+
step: z.number().default(1),
16+
placeholder: z.string().optional().example('Enter number...'),
17+
className: z.string().optional(),
18+
placeholderClassName: z.string().optional(),
19+
placeholderTextColor: z.string().optional(),
20+
hasError: z.boolean().default(false),
21+
readOnly: z.boolean().default(false),
22+
disabled: z.boolean().default(false),
23+
})
24+
25+
type NumberStepperProps = z.input<typeof NumberStepperProps> & {
26+
onChange: (value: number) => void
27+
}
28+
29+
/* --- <NumberStepper/> ------------------------------------------------------------------------ */
30+
31+
export const NumberStepper = forwardRef<
32+
ElementRef<typeof TextInput>,
33+
NumberStepperProps
34+
>((rawProps, ref) => {
35+
// Props
36+
const props = NumberStepperProps.applyDefaults(rawProps)
37+
const { min, max, step, onChange, ...restProps } = props
38+
39+
// State
40+
const [value, setValue] = useState(props.value)
41+
42+
// Helpers
43+
const constrainValue = (value: number) => Math.min(Math.max(value, min), max || Infinity)
44+
45+
// Vars
46+
const numberValue = constrainValue(value)
47+
48+
// Flags
49+
const hasMinValue = typeof rawProps.min !== undefined
50+
const hasMaxValue = typeof rawProps.max !== undefined
51+
const hasReachedMin = hasMinValue && numberValue === min
52+
const hasReachedMax = hasMaxValue && numberValue === max
53+
54+
// -- Handlers --
55+
56+
const onIncrement = () => setValue(constrainValue(numberValue + step))
57+
58+
const onDecrement = () => setValue(constrainValue(numberValue - step))
59+
60+
const onKeyPress = ({ nativeEvent }: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
61+
const isUpKey = nativeEvent.key === 'ArrowUp'
62+
const isDownKey = nativeEvent.key === 'ArrowDown'
63+
if (isUpKey) return onIncrement()
64+
if (isDownKey) return onDecrement()
65+
}
66+
67+
// -- Effects --
68+
69+
useEffect(() => {
70+
if (value) onChange(value)
71+
}, [value])
72+
73+
// -- Render --
74+
75+
return (
76+
<View
77+
className={cn(
78+
'h-10 native:h-12',
79+
'web:flex web:w-full',
80+
'web:max-w-[200px]',
81+
)}
82+
>
83+
<Pressable
84+
className={cn(
85+
"absolute top-0 left-0 items-center justify-center select-none z-10",
86+
"w-10 h-10 native:w-12 native:h-12",
87+
"border-r border-r-input",
88+
hasReachedMin && 'opacity-50',
89+
props.hasError && 'border-r-error',
90+
)}
91+
onPress={onDecrement}
92+
disabled={hasReachedMin}
93+
hitSlop={10}
94+
>
95+
<RemoveFilled size={20} />
96+
</Pressable>
97+
<TextInput
98+
ref={ref}
99+
keyboardType="numeric"
100+
{...restProps}
101+
className={cn(
102+
'text-center',
103+
'px-10 native:px-12',
104+
'web:max-w-[200px]',
105+
props.className,
106+
)}
107+
onKeyPress={onKeyPress}
108+
value={value ? `${value}` : ''}
109+
onChangeText={(newValue = '') => {
110+
// Strip non-numeric characters
111+
const strippedValue = newValue.replace(/[^0-9]/g, '')
112+
// If empty, show placeholder
113+
if (!strippedValue) setValue(undefined as unknown as number)
114+
// Convert to number
115+
const newNumberValue = +strippedValue
116+
// @ts-ignore
117+
setValue(newNumberValue)
118+
}}
119+
/>
120+
<Pressable
121+
className={cn(
122+
"absolute top-0 right-0 items-center justify-center select-none z-10",
123+
"w-10 h-10 native:w-12 native:h-12",
124+
"border-l border-l-input",
125+
hasReachedMax && 'opacity-50',
126+
props.hasError && 'border-l-error',
127+
)}
128+
onPress={onIncrement}
129+
disabled={hasReachedMax}
130+
hitSlop={10}
131+
>
132+
<AddFilled size={20} />
133+
</Pressable>
134+
</View>
135+
)
136+
})
137+
138+

features/@app-core/forms/Select.styled.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@ const SelectComponent = forwardRef<
419419
className={cn(
420420
'text-foreground text-sm',
421421
'native:text-lg',
422+
!value && !!placeholder && 'text-muted',
422423
props.valueClassName,
423424
)}
424425
placeholder={placeholder}

features/@app-core/forms/TextInput.styled.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { forwardRef, ElementRef } from 'react'
22
import { TextInput as BaseTextInput } from '@green-stack/forms/TextInput.primitives'
3-
import { cn } from '@green-stack/utils/tailwindUtils'
3+
import { cn, theme } from '../components/styled'
44
import { z, schema, PropsOf } from '@green-stack/schemas'
55

66
/* --- Props ----------------------------------------------------------------------------------- */
@@ -9,9 +9,11 @@ export const TextInputProps = schema('TextInputProps', {
99
value: z.string().optional(),
1010
placeholder: z.string().optional().example('Start typing...'),
1111
className: z.string().optional(),
12-
placeholderClassName: z.string().optional(),
12+
placeholderClassName: z.string().optional(), // @ts-ignore
13+
placeholderTextColor: z.string().default(theme?.colors?.muted),
1314
hasError: z.boolean().default(false),
1415
readOnly: z.boolean().default(false),
16+
disabled: z.boolean().default(false),
1517
})
1618

1719
export type TextInputProps = PropsOf<typeof BaseTextInput, typeof TextInputProps>
@@ -30,6 +32,11 @@ export const TextInput = forwardRef<
3032
return (
3133
<BaseTextInput
3234
ref={ref}
35+
{...props}
36+
placeholderClassName={cn(
37+
'text-muted',
38+
props.placeholderClassName,
39+
)}
3340
className={cn(
3441
'h-10 rounded-md bg-background px-3 text-base text-foreground',
3542
'border border-input',
@@ -39,10 +46,9 @@ export const TextInput = forwardRef<
3946
'web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2',
4047
'file:border-0 file:bg-transparent file:font-medium',
4148
props.readOnly && 'opacity-50 eb:cursor-not-allowed',
42-
!!props.hasError && 'border border-error',
49+
props.hasError && 'border border-error',
50+
props.className,
4351
)}
44-
placeholderClassName={cn('text--muted-foreground', props.placeholderClassName)}
45-
{...props}
4652
/>
4753
)
4854
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import Svg, { Path } from 'react-native-svg'
2+
import { cssInterop } from 'nativewind'
3+
import { z, iconProps, IconProps } from '../schemas/IconProps'
4+
5+
/* --- Types ----------------------------------------------------------------------------------- */
6+
7+
export const AddFilledProps = iconProps('AddFilled', {
8+
color: z.string().default('#333333'),
9+
})
10+
11+
export type AddFilledProps = IconProps<typeof AddFilledProps>
12+
13+
/* --- <AddFilled/> -------------------------------------------------------------------------- */
14+
15+
export const AddFilledBase = (rawProps: AddFilledProps) => {
16+
// Props
17+
const props = AddFilledProps.applyDefaults(rawProps)
18+
const color = AddFilledProps.getIconColor(props)
19+
// Render
20+
return (
21+
<Svg width={props.size} height={props.size} fill="none" viewBox="0 0 24 24" {...props}>
22+
<Path
23+
fill={color}
24+
fillRule="evenodd"
25+
d="M2 12C2 11.4477 2.44772 11 3 11H21C21.5523 11 22 11.4477 22 12C22 12.5523 21.5523 13 21 13H3C2.44772 13 2 12.5523 2 12Z"
26+
clipRule="evenodd"
27+
/>
28+
<Path
29+
fill={color}
30+
fillRule="evenodd"
31+
d="M12 2C12.5523 2 13 2.44772 13 3V21C13 21.5523 12.5523 22 12 22C11.4477 22 11 21.5523 11 21V3C11 2.44772 11.4477 2 12 2Z"
32+
clipRule="evenodd"
33+
/>
34+
</Svg>
35+
)
36+
}
37+
38+
/* --- Exports --------------------------------------------------------------------------------- */
39+
40+
export const AddFilled = cssInterop(AddFilledBase, {
41+
className: {
42+
target: 'style',
43+
},
44+
})
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Svg, { Path } from 'react-native-svg'
2+
import { cssInterop } from 'nativewind'
3+
import { z, iconProps, IconProps } from '../schemas/IconProps'
4+
5+
/* --- Types ----------------------------------------------------------------------------------- */
6+
7+
export const RemoveFilledProps = iconProps('RemoveFilled', {
8+
color: z.string().default('#333333'),
9+
})
10+
11+
export type RemoveFilledProps = IconProps<typeof RemoveFilledProps>
12+
13+
/* --- <RemoveFilled/> -------------------------------------------------------------------------- */
14+
15+
export const RemoveFilledBase = (rawProps: RemoveFilledProps) => {
16+
// Props
17+
const props = RemoveFilledProps.applyDefaults(rawProps)
18+
const color = RemoveFilledProps.getIconColor(props)
19+
// Render
20+
return (
21+
<Svg width={props.size} height={props.size} fill="none" viewBox="0 0 24 24" {...props}>
22+
<Path
23+
fill={color}
24+
fillRule="evenodd"
25+
d="M2 12C2 11.4477 2.44772 11 3 11H21C21.5523 11 22 11.4477 22 12C22 12.5523 21.5523 13 21 13H3C2.44772 13 2 12.5523 2 12Z"
26+
clipRule="evenodd"
27+
/>
28+
</Svg>
29+
)
30+
}
31+
32+
/* --- Exports --------------------------------------------------------------------------------- */
33+
34+
export const RemoveFilled = cssInterop(RemoveFilledBase, {
35+
className: {
36+
target: 'style',
37+
},
38+
})

0 commit comments

Comments
 (0)