@neko-os/ui 0.0.12 → 0.1.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.
Files changed (143) hide show
  1. package/dist/abstractions/KeyboardAvoidingView.js +1 -0
  2. package/dist/abstractions/KeyboardAvoidingView.native.js +1 -0
  3. package/dist/components/actions/ActionsDrawer.js +1 -0
  4. package/dist/components/actions/Button.js +1 -1
  5. package/dist/components/actions/Dropdown.js +1 -1
  6. package/dist/components/actions/FloatingMenu.js +1 -0
  7. package/dist/components/actions/index.js +1 -1
  8. package/dist/components/actions/menu/VerticalMenu.js +1 -1
  9. package/dist/components/animations/AnimatedTopBar.js +1 -0
  10. package/dist/components/animations/AnimatedTopBar.native.js +1 -0
  11. package/dist/components/animations/AnimatedTopBar.web.js +1 -0
  12. package/dist/components/animations/ParallaxHeader.js +1 -0
  13. package/dist/components/animations/ParallaxHeader.native.js +1 -0
  14. package/dist/components/animations/ParallaxHeader.web.js +1 -0
  15. package/dist/components/animations/ReanimatedScrollHandler.js +1 -0
  16. package/dist/components/animations/ReanimatedScrollHandler.native.js +1 -0
  17. package/dist/components/animations/ReanimatedScrollHandler.web.js +1 -0
  18. package/dist/components/animations/index.js +1 -1
  19. package/dist/components/form/FormItem.js +1 -1
  20. package/dist/components/form/FormList.js +1 -1
  21. package/dist/components/form/SubmitButton.js +1 -1
  22. package/dist/components/form/index.js +1 -1
  23. package/dist/components/form/useNewForm.js +1 -1
  24. package/dist/components/form/validation/defaultMessages.js +1 -0
  25. package/dist/components/form/validation/index.js +1 -0
  26. package/dist/components/form/validation/normalizeRules.js +1 -0
  27. package/dist/components/form/validation/shouldValidateOn.js +1 -0
  28. package/dist/components/form/validation/validateRules.js +1 -0
  29. package/dist/components/form/validation/validators.js +1 -0
  30. package/dist/components/index.js +1 -1
  31. package/dist/components/inputs/InputWrapper.js +1 -1
  32. package/dist/components/inputs/NumberInput.js +1 -1
  33. package/dist/components/inputs/Picker.js +1 -1
  34. package/dist/components/inputs/Select.js +1 -1
  35. package/dist/components/presentation/Avatar.js +1 -1
  36. package/dist/components/presentation/AvatarLabel.js +1 -1
  37. package/dist/components/presentation/LabelValue.js +1 -1
  38. package/dist/components/presentation/Result.js +1 -1
  39. package/dist/components/presentation/Tooltip.js +1 -1
  40. package/dist/components/sections/Section.js +1 -0
  41. package/dist/components/sections/SectionItem.js +1 -0
  42. package/dist/components/sections/SectionItemLink.js +1 -0
  43. package/dist/components/sections/index.js +1 -0
  44. package/dist/components/state/StatePresenter.js +1 -0
  45. package/dist/components/state/index.js +1 -1
  46. package/dist/components/structure/BlurView.js +1 -1
  47. package/dist/components/structure/KeyboardAvoidingView.js +1 -0
  48. package/dist/components/structure/TopBar.js +1 -0
  49. package/dist/components/structure/bottomDrawer/index.js +1 -1
  50. package/dist/components/structure/bottomDrawer/index.native.js +1 -1
  51. package/dist/components/structure/bottomDrawer/index.web.js +1 -1
  52. package/dist/components/structure/bottomDrawer/native/BottomDrawer.js +1 -1
  53. package/dist/components/structure/bottomDrawer/native/DrawerScrollView.js +1 -1
  54. package/dist/components/structure/bottomDrawer/native/createDrawerScrollComponent.js +1 -0
  55. package/dist/components/structure/drawer/Drawer.web.js +1 -0
  56. package/dist/components/structure/index.js +1 -1
  57. package/dist/components/text/DateText.js +1 -0
  58. package/dist/components/text/index.js +1 -1
  59. package/dist/components/theme/ThemePicker.js +1 -1
  60. package/dist/helpers/index.js +1 -1
  61. package/dist/helpers/storage.js +1 -1
  62. package/dist/responsive/responsiveHooks.js +1 -1
  63. package/dist/theme/ThemeHandler.js +1 -1
  64. package/dist/theme/default/base.js +1 -1
  65. package/dist/theme/default/blackTheme.js +1 -1
  66. package/dist/theme/default/cyberpunkTheme.js +1 -1
  67. package/dist/theme/default/darkTheme.js +1 -1
  68. package/dist/theme/default/hackerTheme.js +1 -1
  69. package/dist/theme/default/lightTheme.js +1 -1
  70. package/dist/theme/default/msdosTheme.js +1 -1
  71. package/dist/theme/default/paperTheme.js +1 -1
  72. package/package.json +1 -1
  73. package/src/abstractions/KeyboardAvoidingView.js +3 -0
  74. package/src/abstractions/KeyboardAvoidingView.native.js +3 -0
  75. package/src/components/actions/ActionsDrawer.js +68 -0
  76. package/src/components/actions/Button.js +2 -1
  77. package/src/components/actions/Dropdown.js +11 -8
  78. package/src/components/actions/FloatingMenu.js +39 -0
  79. package/src/components/actions/index.js +2 -0
  80. package/src/components/actions/menu/VerticalMenu.js +1 -2
  81. package/src/components/animations/AnimatedTopBar.js +10 -0
  82. package/src/components/animations/AnimatedTopBar.native.js +34 -0
  83. package/src/components/animations/AnimatedTopBar.web.js +1 -0
  84. package/src/components/animations/ParallaxHeader.js +9 -0
  85. package/src/components/animations/ParallaxHeader.native.js +32 -0
  86. package/src/components/animations/ParallaxHeader.web.js +32 -0
  87. package/src/components/animations/ReanimatedScrollHandler.js +8 -0
  88. package/src/components/animations/ReanimatedScrollHandler.native.js +24 -0
  89. package/src/components/animations/ReanimatedScrollHandler.web.js +1 -0
  90. package/src/components/animations/index.js +3 -0
  91. package/src/components/form/FormItem.js +42 -5
  92. package/src/components/form/FormList.js +23 -4
  93. package/src/components/form/SubmitButton.js +4 -2
  94. package/src/components/form/index.js +1 -0
  95. package/src/components/form/useNewForm.js +108 -15
  96. package/src/components/form/validation/defaultMessages.js +20 -0
  97. package/src/components/form/validation/index.js +5 -0
  98. package/src/components/form/validation/normalizeRules.js +22 -0
  99. package/src/components/form/validation/shouldValidateOn.js +21 -0
  100. package/src/components/form/validation/validateRules.js +83 -0
  101. package/src/components/form/validation/validators.js +82 -0
  102. package/src/components/index.js +1 -0
  103. package/src/components/inputs/InputWrapper.js +1 -1
  104. package/src/components/inputs/NumberInput.js +6 -5
  105. package/src/components/inputs/Picker.js +3 -2
  106. package/src/components/inputs/Select.js +31 -15
  107. package/src/components/presentation/Avatar.js +2 -2
  108. package/src/components/presentation/AvatarLabel.js +2 -0
  109. package/src/components/presentation/LabelValue.js +7 -5
  110. package/src/components/presentation/Result.js +2 -2
  111. package/src/components/presentation/Tooltip.js +1 -1
  112. package/src/components/sections/Section.js +50 -0
  113. package/src/components/sections/SectionItem.js +24 -0
  114. package/src/components/sections/SectionItemLink.js +33 -0
  115. package/src/components/sections/index.js +3 -0
  116. package/src/components/state/StatePresenter.js +41 -0
  117. package/src/components/state/index.js +1 -0
  118. package/src/components/structure/BlurView.js +1 -0
  119. package/src/components/structure/KeyboardAvoidingView.js +52 -0
  120. package/src/components/structure/TopBar.js +45 -0
  121. package/src/components/structure/bottomDrawer/index.js +2 -0
  122. package/src/components/structure/bottomDrawer/index.native.js +2 -1
  123. package/src/components/structure/bottomDrawer/index.web.js +2 -1
  124. package/src/components/structure/bottomDrawer/native/BottomDrawer.js +14 -20
  125. package/src/components/structure/bottomDrawer/native/DrawerScrollView.js +4 -82
  126. package/src/components/structure/bottomDrawer/native/createDrawerScrollComponent.js +131 -0
  127. package/src/components/structure/drawer/Drawer.web.js +3 -0
  128. package/src/components/structure/index.js +2 -0
  129. package/src/components/text/DateText.js +11 -0
  130. package/src/components/text/index.js +1 -0
  131. package/src/components/theme/ThemePicker.js +1 -2
  132. package/src/helpers/index.js +1 -0
  133. package/src/helpers/storage.js +32 -9
  134. package/src/responsive/responsiveHooks.js +6 -0
  135. package/src/theme/ThemeHandler.js +6 -3
  136. package/src/theme/default/base.js +16 -4
  137. package/src/theme/default/blackTheme.js +1 -0
  138. package/src/theme/default/cyberpunkTheme.js +10 -0
  139. package/src/theme/default/darkTheme.js +1 -0
  140. package/src/theme/default/hackerTheme.js +17 -3
  141. package/src/theme/default/lightTheme.js +1 -0
  142. package/src/theme/default/msdosTheme.js +9 -10
  143. package/src/theme/default/paperTheme.js +10 -0
@@ -5,38 +5,75 @@ import { Text } from '../text/Text'
5
5
  import { View } from '../structure/View'
6
6
  import { clearProps } from '../../modifiers/_helpers'
7
7
  import { useFormInstance, useFormState } from './Form'
8
+ import { shouldValidateOn } from './validation'
8
9
 
9
- export function FormItem({ name, label, isAbsolutePath, children, useDefaultValue, ...props }) {
10
+ export function FormItem({
11
+ name,
12
+ label,
13
+ isAbsolutePath,
14
+ children,
15
+ useDefaultValue,
16
+ rules,
17
+ validateTrigger = 'onSubmit',
18
+ ...props
19
+ }) {
10
20
  const form = useFormInstance()
11
21
  const formState = useFormState()
12
22
  const listPath = useRelativePath(name, { isAbsolutePath })
23
+ const listPathStr = listPath.join('$NEKOJOIN$')
13
24
  const [value, setValue] = React.useState(form.getFieldValue(listPath))
14
- const error = form.getError(listPath)
25
+ const [error, setError] = React.useState(form.getError(listPath))
15
26
 
27
+ // Register rules with the form
28
+ React.useEffect(() => {
29
+ return form.registerRules(listPath, rules, validateTrigger)
30
+ }, [listPathStr, JSON.stringify(rules), validateTrigger])
31
+
32
+ // Listen for value changes
16
33
  React.useEffect(() => {
17
34
  return form.registerListener(listPath, (val) => setValue(val))
18
- }, [listPath.join('$NEKOJOIN$')])
35
+ }, [listPathStr])
36
+
37
+ // Listen for error changes
38
+ React.useEffect(() => {
39
+ return form.registerErrorListener(listPath, (err) => setError(err))
40
+ }, [listPathStr])
19
41
 
20
42
  const handleChange = (e) => {
21
43
  const val = e?.target?.value ?? e
22
44
  form.setFieldValue(listPath, val)
45
+
46
+ if (shouldValidateOn('onChange', rules, validateTrigger)) {
47
+ form.validateField(listPath, 'onChange')
48
+ }
49
+ }
50
+
51
+ const handleBlur = (e, originalOnBlur) => {
52
+ if (originalOnBlur) originalOnBlur(e)
53
+
54
+ if (shouldValidateOn('onBlur', rules, validateTrigger)) {
55
+ form.validateField(listPath, 'onBlur')
56
+ }
23
57
  }
24
58
 
25
59
  let valueKey = 'value'
26
60
  if (!!useDefaultValue) valueKey = 'defaultValue'
27
61
 
62
+ const child = typeof children === 'function' ? null : React.Children.only(children)
63
+ const originalOnBlur = child?.props?.onBlur
64
+
28
65
  const childProps = clearProps({
29
66
  [valueKey]: value === undefined ? '' : value,
30
67
  onChange: handleChange,
31
- // loading: formState?.loading === true || undefined,
68
+ onBlur: (e) => handleBlur(e, originalOnBlur),
32
69
  disabled: formState?.disabled === true || undefined,
70
+ error: !!error || undefined,
33
71
  })
34
72
 
35
73
  let content
36
74
  if (typeof children === 'function') {
37
75
  content = children(childProps)
38
76
  } else {
39
- const child = React.Children.only(children)
40
77
  content = React.cloneElement(child, { ...child.props, ...childProps })
41
78
  }
42
79
 
@@ -3,22 +3,32 @@ import React from 'react'
3
3
  import { FormGroup, useRelativePath } from './FormGroup'
4
4
  import { Text } from '../text/Text'
5
5
  import { useFormInstance } from './Form'
6
+ import { shouldValidateOn } from './validation'
6
7
 
7
8
  const FormListContext = React.createContext(null)
8
9
  const useFormList = () => React.useContext(FormListContext)
9
10
 
10
- export function FormList({ name, isAbsolutePath, children }) {
11
+ export function FormList({ name, isAbsolutePath, children, rules, validateTrigger = 'onSubmit' }) {
11
12
  const form = useFormInstance()
12
13
  const listPath = useRelativePath(name, { isAbsolutePath })
13
- // To avoid watch being recalled
14
14
  const listPathStr = listPath.join('$NEKOJOIN$')
15
- const error = form.getError(listPath)
15
+ const [error, setError] = React.useState(form.getError(listPath))
16
16
 
17
17
  // Counter to generate unique keys
18
18
  const keyCounter = React.useRef(0)
19
19
  // Map to track keys by value reference
20
20
  const keysMap = React.useRef(new WeakMap())
21
21
 
22
+ // Register rules with the form
23
+ React.useEffect(() => {
24
+ return form.registerRules(listPath, rules, validateTrigger)
25
+ }, [listPathStr, JSON.stringify(rules), validateTrigger])
26
+
27
+ // Listen for error changes
28
+ React.useEffect(() => {
29
+ return form.registerErrorListener(listPath, (err) => setError(err))
30
+ }, [listPathStr])
31
+
22
32
  const generateFields = (items) => {
23
33
  if (!Array.isArray(items)) return []
24
34
  return items.map((item, index) => {
@@ -47,14 +57,22 @@ export function FormList({ name, isAbsolutePath, children }) {
47
57
  })
48
58
  }, [listPathStr])
49
59
 
60
+ const validateOnChange = () => {
61
+ if (shouldValidateOn('onChange', rules, validateTrigger)) {
62
+ form.validateField(listPath, 'onChange')
63
+ }
64
+ }
65
+
50
66
  const add = (defaultValue = {}) => {
51
67
  const current = form.getFieldValue(listPath) || []
52
68
  form.setFieldValue(listPath, [...current, defaultValue])
69
+ validateOnChange()
53
70
  }
54
71
 
55
72
  const addOn = (index, defaultValue = {}) => {
56
73
  const current = form.getFieldValue(listPath) || []
57
74
  form.setFieldValue(listPath, [...current.slice(0, index), defaultValue, ...current.slice(index)])
75
+ validateOnChange()
58
76
  }
59
77
 
60
78
  const replace = (index, value) => {
@@ -91,6 +109,7 @@ export function FormList({ name, isAbsolutePath, children }) {
91
109
  listPath,
92
110
  current.filter((_, i) => i !== index)
93
111
  )
112
+ validateOnChange()
94
113
  }
95
114
 
96
115
  const actions = React.useMemo(
@@ -102,7 +121,7 @@ export function FormList({ name, isAbsolutePath, children }) {
102
121
  move,
103
122
  duplicate,
104
123
  }),
105
- [listPathStr]
124
+ [listPathStr, rules, validateTrigger]
106
125
  )
107
126
 
108
127
  let content
@@ -1,12 +1,14 @@
1
1
  import { Button } from '../actions/Button'
2
2
  import { useFormInstance, useFormState } from './Form'
3
3
 
4
- export function SubmitButton({ form, disabled, ...props }) {
4
+ export function SubmitButton({ form, disabled, Wrapper, ...props }) {
5
5
  const formState = useFormState()
6
6
  const contextForm = useFormInstance()
7
7
  form = form || contextForm
8
8
  disabled = formState?.disabled || disabled
9
9
 
10
+ Wrapper = Wrapper || Button
11
+
10
12
  const handleSubmit = () => {
11
13
  if (!form) {
12
14
  console.error('No form provided to useWatch. Pass it as params or wrap it inside a <Form> component.')
@@ -16,5 +18,5 @@ export function SubmitButton({ form, disabled, ...props }) {
16
18
  form.handleSubmit()
17
19
  }
18
20
 
19
- return <Button {...props} disabled={disabled} onPress={handleSubmit} />
21
+ return <Wrapper {...props} disabled={disabled} onPress={handleSubmit} />
20
22
  }
@@ -6,3 +6,4 @@ export * from './FormGroup'
6
6
  export * from './useNewForm'
7
7
  export * from './useWatch'
8
8
  export * from './SubmitButton'
9
+ export * from './validation'
@@ -1,34 +1,61 @@
1
1
  import { assocPath, path } from 'ramda'
2
2
  import React from 'react'
3
+ import { validateRules, validateAllFields, normalizeRules } from './validation'
3
4
 
4
5
  export function useNewForm({ initialValues = {}, validate, onSubmit } = {}) {
5
6
  const valuesRef = React.useRef({ ...initialValues })
6
- const errorsRef = React.useRef({})
7
+ const errorsRef = React.useRef({}) // Flat structure: { 'users': 'error', 'users.0.name': 'error' }
7
8
  const listenersRef = React.useRef({})
9
+ const errorListenersRef = React.useRef({})
10
+ const rulesRegistryRef = React.useRef(new Map())
8
11
 
9
12
  const formApi = React.useMemo(() => {
13
+ const toKey = (name) => (Array.isArray(name) ? name.join('.') : name)
14
+ const toPath = (name) => (Array.isArray(name) ? name : [name])
15
+
10
16
  const notify = (name) => {
11
- const key = Array.isArray(name) ? name.join('.') : name
17
+ const key = toKey(name)
12
18
  if (listenersRef.current[key]) {
13
- listenersRef.current[key].forEach((cb) => cb(path(name, valuesRef.current)))
19
+ listenersRef.current[key].forEach((cb) => cb(path(toPath(name), valuesRef.current)))
20
+ }
21
+ }
22
+
23
+ const notifyError = (name) => {
24
+ const key = toKey(name)
25
+ if (errorListenersRef.current[key]) {
26
+ errorListenersRef.current[key].forEach((cb) => cb(errorsRef.current[key]))
14
27
  }
15
28
  }
16
29
 
17
30
  const setFieldValue = (name, value) => {
18
- valuesRef.current = assocPath(name, value, valuesRef.current)
31
+ valuesRef.current = assocPath(toPath(name), value, valuesRef.current)
19
32
  notify(name)
20
33
  }
21
34
 
22
- const getFieldValue = (name) => path(name, valuesRef.current)
35
+ const getFieldValue = (name) => path(toPath(name), valuesRef.current)
23
36
 
24
- const getError = (name) => path(name, errorsRef.current)
37
+ // Flat error lookup by key
38
+ const getError = (name) => {
39
+ const key = toKey(name)
40
+ return errorsRef.current[key]
41
+ }
25
42
 
26
43
  const setError = (name, error) => {
27
- errorsRef.current = assocPath(name, error, errorsRef.current)
44
+ const key = toKey(name)
45
+ if (error) {
46
+ errorsRef.current[key] = error
47
+ } else {
48
+ delete errorsRef.current[key]
49
+ }
50
+ notifyError(name)
51
+ }
52
+
53
+ const clearErrors = () => {
54
+ errorsRef.current = {}
28
55
  }
29
56
 
30
57
  const registerListener = (name, cb) => {
31
- const key = Array.isArray(name) ? name.join('.') : name
58
+ const key = toKey(name)
32
59
  if (!listenersRef.current[key]) {
33
60
  listenersRef.current[key] = []
34
61
  }
@@ -38,15 +65,77 @@ export function useNewForm({ initialValues = {}, validate, onSubmit } = {}) {
38
65
  }
39
66
  }
40
67
 
41
- const validateForm = () => {
42
- if (!validate) return true
43
- const newErrors = validate(valuesRef.current) || {}
44
- errorsRef.current = newErrors
45
- return Object.keys(newErrors).length === 0
68
+ const registerErrorListener = (name, cb) => {
69
+ const key = toKey(name)
70
+ if (!errorListenersRef.current[key]) {
71
+ errorListenersRef.current[key] = []
72
+ }
73
+ errorListenersRef.current[key].push(cb)
74
+ return () => {
75
+ errorListenersRef.current[key] = errorListenersRef.current[key].filter((fn) => fn !== cb)
76
+ }
77
+ }
78
+
79
+ const registerRules = (name, rules, defaultTrigger = 'onSubmit') => {
80
+ if (!rules) return
81
+ const key = toKey(name)
82
+ const rulesArray = normalizeRules(rules).map((rule) => ({
83
+ ...rule,
84
+ trigger: rule.trigger || defaultTrigger,
85
+ }))
86
+ rulesRegistryRef.current.set(key, { path: name, rules: rulesArray })
87
+ return () => rulesRegistryRef.current.delete(key)
88
+ }
89
+
90
+ const validateField = async (name, trigger = 'onSubmit') => {
91
+ const key = toKey(name)
92
+ const entry = rulesRegistryRef.current.get(key)
93
+ if (!entry) return null
94
+
95
+ const value = path(name, valuesRef.current)
96
+ const error = await validateRules(value, entry.rules, trigger)
97
+
98
+ if (error) {
99
+ errorsRef.current[key] = error
100
+ } else {
101
+ delete errorsRef.current[key]
102
+ }
103
+ notifyError(name)
104
+ return error
105
+ }
106
+
107
+ const validateForm = async () => {
108
+ // Clear previous errors
109
+ errorsRef.current = {}
110
+
111
+ // Run rules-based validation
112
+ const rulesErrors = await validateAllFields(valuesRef.current, rulesRegistryRef.current)
113
+
114
+ // Run legacy validate function if provided
115
+ const legacyErrors = validate ? validate(valuesRef.current) || {} : {}
116
+
117
+ // Store errors in flat structure
118
+ Object.entries(rulesErrors).forEach(([key, error]) => {
119
+ errorsRef.current[key] = error
120
+ })
121
+
122
+ // Legacy errors are already flat (or should be converted)
123
+ Object.entries(legacyErrors).forEach(([key, error]) => {
124
+ if (!errorsRef.current[key]) {
125
+ errorsRef.current[key] = error
126
+ }
127
+ })
128
+
129
+ // Notify all error listeners
130
+ rulesRegistryRef.current.forEach((_, key) => {
131
+ notifyError(key)
132
+ })
133
+
134
+ return Object.keys(errorsRef.current).length === 0
46
135
  }
47
136
 
48
- const handleSubmit = () => {
49
- const isValid = validateForm()
137
+ const handleSubmit = async () => {
138
+ const isValid = await validateForm()
50
139
  if (!isValid) return
51
140
  console.log('SUBMIT')
52
141
  onSubmit(valuesRef.current)
@@ -57,7 +146,11 @@ export function useNewForm({ initialValues = {}, validate, onSubmit } = {}) {
57
146
  getFieldValue,
58
147
  getError,
59
148
  setError,
149
+ clearErrors,
60
150
  registerListener,
151
+ registerErrorListener,
152
+ registerRules,
153
+ validateField,
61
154
  handleSubmit,
62
155
  valuesRef,
63
156
  }
@@ -0,0 +1,20 @@
1
+ export const defaultMessages = {
2
+ required: 'This field is required',
3
+ type: {
4
+ email: 'Please enter a valid email address',
5
+ url: 'Please enter a valid URL',
6
+ number: 'Please enter a valid number',
7
+ integer: 'Please enter a valid integer',
8
+ },
9
+ min: {
10
+ string: (min) => `Must be at least ${min} characters`,
11
+ number: (min) => `Must be at least ${min}`,
12
+ array: (min) => `Must have at least ${min} items`,
13
+ },
14
+ max: {
15
+ string: (max) => `Must be at most ${max} characters`,
16
+ number: (max) => `Must be at most ${max}`,
17
+ array: (max) => `Must have at most ${max} items`,
18
+ },
19
+ pattern: 'Invalid format',
20
+ }
@@ -0,0 +1,5 @@
1
+ export { defaultMessages } from './defaultMessages'
2
+ export { validators } from './validators'
3
+ export { validateRules, validateAllFields } from './validateRules'
4
+ export { normalizeRules } from './normalizeRules'
5
+ export { shouldValidateOn } from './shouldValidateOn'
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Normalizes rules to array format.
3
+ * Accepts either array format or object shorthand.
4
+ *
5
+ * Array format (full control):
6
+ * [{ required: true, message: 'Required' }, { min: 2 }]
7
+ *
8
+ * Object shorthand (simple cases):
9
+ * { required: true, min: 2, max: 7, type: 'email' }
10
+ * -> converts to: [{ required: true }, { min: 2 }, { max: 7 }, { type: 'email' }]
11
+ *
12
+ * @param {Array|Object} rules
13
+ * @returns {Array}
14
+ */
15
+ export function normalizeRules(rules) {
16
+ if (!rules) return []
17
+ if (Array.isArray(rules)) return rules
18
+ if (typeof rules === 'object') {
19
+ return Object.entries(rules).map(([key, value]) => ({ [key]: value }))
20
+ }
21
+ return []
22
+ }
@@ -0,0 +1,21 @@
1
+ import { normalizeRules } from './normalizeRules'
2
+
3
+ /**
4
+ * Checks if validation should run for a given trigger.
5
+ * @param {string} trigger - The trigger to check ('onChange', 'onBlur', 'onSubmit')
6
+ * @param {Array|Object} rules - The rules (array or object format)
7
+ * @param {string|string[]} validateTrigger - The default trigger(s) for the field
8
+ * @returns {boolean}
9
+ */
10
+ export function shouldValidateOn(trigger, rules, validateTrigger = 'onSubmit') {
11
+ if (!rules) return false
12
+
13
+ const triggers = Array.isArray(validateTrigger) ? validateTrigger : [validateTrigger]
14
+ if (triggers.includes(trigger)) return true
15
+
16
+ // Check per-rule triggers
17
+ const rulesArray = normalizeRules(rules)
18
+ return rulesArray.some(
19
+ (rule) => rule.trigger === trigger || (Array.isArray(rule.trigger) && rule.trigger.includes(trigger))
20
+ )
21
+ }
@@ -0,0 +1,83 @@
1
+ import { path as getPath } from 'ramda'
2
+ import { validators } from './validators'
3
+
4
+ /**
5
+ * Validates a value against an array of rules
6
+ * @param {any} value - The value to validate
7
+ * @param {Array} rules - Array of rule objects
8
+ * @param {string} trigger - Current validation trigger ('onSubmit', 'onBlur', 'onChange')
9
+ * @returns {Promise<string|null>} - First error message or null if valid
10
+ */
11
+ export async function validateRules(value, rules, trigger = 'onSubmit') {
12
+ if (!rules || rules.length === 0) return null
13
+
14
+ for (const rule of rules) {
15
+ const ruleTrigger = rule.trigger || 'onSubmit'
16
+ const triggers = Array.isArray(ruleTrigger) ? ruleTrigger : [ruleTrigger]
17
+
18
+ // Always run on submit, otherwise check trigger match
19
+ if (trigger !== 'onSubmit' && !triggers.includes(trigger)) {
20
+ continue
21
+ }
22
+
23
+ let error = null
24
+
25
+ // Custom validator takes precedence
26
+ if (rule.validator) {
27
+ try {
28
+ await rule.validator(rule, value)
29
+ } catch (e) {
30
+ error = e.message || rule.message || 'Validation failed'
31
+ }
32
+ } else {
33
+ // Run built-in validators
34
+ if (rule.required) {
35
+ error = validators.required(value, rule)
36
+ }
37
+ if (!error && rule.type) {
38
+ error = validators.type(value, rule)
39
+ }
40
+ if (!error && rule.min !== undefined) {
41
+ error = validators.min(value, rule)
42
+ }
43
+ if (!error && rule.max !== undefined) {
44
+ error = validators.max(value, rule)
45
+ }
46
+ if (!error && rule.pattern) {
47
+ error = validators.pattern(value, rule)
48
+ }
49
+ }
50
+
51
+ if (error) {
52
+ return error
53
+ }
54
+ }
55
+
56
+ return null
57
+ }
58
+
59
+ /**
60
+ * Validates multiple fields at once (for form-level validation)
61
+ * @param {Object} values - All form values
62
+ * @param {Map} rulesRegistry - Map of field path keys to { path: array, rules: array }
63
+ * @returns {Promise<Object>} - Object of path key -> error message
64
+ */
65
+ export async function validateAllFields(values, rulesRegistry) {
66
+ const errors = {}
67
+ const validationPromises = []
68
+
69
+ rulesRegistry.forEach(({ path, rules }, pathKey) => {
70
+ const value = getPath(path, values)
71
+
72
+ validationPromises.push(
73
+ validateRules(value, rules, 'onSubmit').then((error) => {
74
+ if (error) {
75
+ errors[pathKey] = error
76
+ }
77
+ })
78
+ )
79
+ })
80
+
81
+ await Promise.all(validationPromises)
82
+ return errors
83
+ }
@@ -0,0 +1,82 @@
1
+ import { defaultMessages } from './defaultMessages'
2
+
3
+ const isEmpty = (value) => {
4
+ if (value === undefined || value === null) return true
5
+ if (typeof value === 'string' && value.trim() === '') return true
6
+ if (Array.isArray(value) && value.length === 0) return true
7
+ return false
8
+ }
9
+
10
+ export const validators = {
11
+ required: (value, rule) => {
12
+ if (isEmpty(value)) {
13
+ return rule.message || defaultMessages.required
14
+ }
15
+ return null
16
+ },
17
+
18
+ type: (value, rule) => {
19
+ if (isEmpty(value)) return null
20
+
21
+ const typeValidators = {
22
+ email: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
23
+ url: (v) => {
24
+ try {
25
+ new URL(v)
26
+ return true
27
+ } catch {
28
+ return false
29
+ }
30
+ },
31
+ number: (v) => !isNaN(Number(v)),
32
+ integer: (v) => Number.isInteger(Number(v)) && !isNaN(Number(v)),
33
+ }
34
+
35
+ const validator = typeValidators[rule.type]
36
+ if (validator && !validator(value)) {
37
+ return rule.message || defaultMessages.type[rule.type] || `Invalid ${rule.type}`
38
+ }
39
+ return null
40
+ },
41
+
42
+ min: (value, rule) => {
43
+ if (isEmpty(value)) return null
44
+
45
+ if (typeof value === 'string' && value.length < rule.min) {
46
+ return rule.message || defaultMessages.min.string(rule.min)
47
+ }
48
+ if (typeof value === 'number' && value < rule.min) {
49
+ return rule.message || defaultMessages.min.number(rule.min)
50
+ }
51
+ if (Array.isArray(value) && value.length < rule.min) {
52
+ return rule.message || defaultMessages.min.array(rule.min)
53
+ }
54
+ return null
55
+ },
56
+
57
+ max: (value, rule) => {
58
+ if (isEmpty(value)) return null
59
+
60
+ if (typeof value === 'string' && value.length > rule.max) {
61
+ return rule.message || defaultMessages.max.string(rule.max)
62
+ }
63
+ if (typeof value === 'number' && value > rule.max) {
64
+ return rule.message || defaultMessages.max.number(rule.max)
65
+ }
66
+ if (Array.isArray(value) && value.length > rule.max) {
67
+ return rule.message || defaultMessages.max.array(rule.max)
68
+ }
69
+ return null
70
+ },
71
+
72
+ pattern: (value, rule) => {
73
+ if (isEmpty(value)) return null
74
+
75
+ const regex = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern)
76
+
77
+ if (!regex.test(String(value))) {
78
+ return rule.message || defaultMessages.pattern
79
+ }
80
+ return null
81
+ },
82
+ }
@@ -14,3 +14,4 @@ export * from './calendar'
14
14
  export * from './list'
15
15
  export * from './tabs'
16
16
  export * from './theme'
17
+ export * from './sections'
@@ -48,7 +48,7 @@ export function InputWrapper({
48
48
  if (!!suffix && is(String, suffix)) suffix = <Text>{suffix}</Text>
49
49
  if (!prefix && !!prefixIcon) prefix = <Icon name={prefixIcon} size={sizeCode} color={prefixIconColor} />
50
50
  if (!suffix && !!suffixIcon) suffix = <Icon name={suffixIcon} size={sizeCode} color={suffixIconColor} />
51
- if (!prefix && !!error) suffix = <Icon name="close-circle-fill" size={sizeCode} red />
51
+ if (!prefix && !!error) suffix = <Icon name="alert-fill" size={sizeCode} red />
52
52
  if (!!loading) suffix = <Loading size={sizeCode} />
53
53
 
54
54
  let borderColor = !!hover ? 'primary_op40' : 'divider'
@@ -73,11 +73,11 @@ export function formatNumericValue(newValue, prevValue, options = {}) {
73
73
  return numericValue
74
74
  }
75
75
 
76
- export function NumberInput({ onChange, value, useInt, precision, min, max, error, ...props }) {
76
+ export function NumberInput({ onChange, onBlur, value, useInt, precision, min, max, error, ...props }) {
77
77
  const [hasError, setHasError] = React.useState(false)
78
78
  const [inputValue, setInputValue] = React.useState(value)
79
79
  const [localValue, setLocalValue] = React.useState(value)
80
- React.useEffect(() => setInputValue(value), [value])
80
+ React.useEffect(() => setInputValue(value?.toString() || ''), [value])
81
81
 
82
82
  if (useInt) precision = 0
83
83
  if (!useInt && precision === 0) useInt = true
@@ -85,6 +85,7 @@ export function NumberInput({ onChange, value, useInt, precision, min, max, erro
85
85
 
86
86
  return (
87
87
  <TextInput
88
+ {...props}
88
89
  onChange={(newValue) => {
89
90
  const numericValue = formatNumericValue(newValue, localValue, opts)
90
91
  setInputValue(newValue?.toString() || '')
@@ -92,14 +93,14 @@ export function NumberInput({ onChange, value, useInt, precision, min, max, erro
92
93
  onChange?.(numericValue)
93
94
  setHasError(!isValidNumber(newValue, opts))
94
95
  }}
95
- onBlur={() => {
96
- setInputValue(localValue)
96
+ onBlur={(e) => {
97
+ setInputValue(localValue?.toString() || '')
97
98
  setHasError(!isValidNumber(localValue, opts))
99
+ onBlur?.(e)
98
100
  }}
99
101
  value={inputValue}
100
102
  keyboardType={useInt ? 'number-pad' : 'decimal-pad'}
101
103
  error={error || hasError}
102
- {...props}
103
104
  />
104
105
  )
105
106
  }
@@ -2,9 +2,10 @@ import { is } from 'ramda'
2
2
  import React from 'react'
3
3
 
4
4
  import { Col } from '../structure/Col'
5
- import { FlatList } from '../list/FlatList'
5
+ import { DrawerFlatList } from '../structure/bottomDrawer'
6
6
  import { LoadingView } from '../state/LoadingView'
7
7
  import { Row } from '../structure/Row'
8
+ import { View } from '../structure'
8
9
  import { normalizeString } from '../../helpers/string'
9
10
  import { useOptions } from '../../helpers/options'
10
11
 
@@ -94,7 +95,7 @@ function DefaultPickerWrapper({ renderItem, options, ...props }) {
94
95
 
95
96
  function FlatListPickerWrapper({ renderItem, options, valueKey, ...props }) {
96
97
  return (
97
- <FlatList
98
+ <DrawerFlatList
98
99
  keyExtractor={(i) => i[valueKey]}
99
100
  data={options}
100
101
  divider