@neko-os/ui 0.5.4 → 0.6.1

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 (135) hide show
  1. package/dist/NekoUI.js +12 -9
  2. package/dist/abstractions/WindowOverlay.js +3 -0
  3. package/dist/abstractions/WindowOverlay.native.js +21 -0
  4. package/dist/abstractions/helpers/storage.js +14 -4
  5. package/dist/abstractions/helpers/storage.native.js +9 -1
  6. package/dist/components/feedback/notifications/NotificationsHandler.js +10 -6
  7. package/dist/components/index.js +3 -1
  8. package/dist/components/inputs/DateInput.js +10 -6
  9. package/dist/components/inputs/InputWrapper.js +2 -3
  10. package/dist/components/inputs/NumberWheelInput.js +50 -0
  11. package/dist/components/inputs/NumberWheelPicker.js +43 -0
  12. package/dist/components/inputs/UploadInput.js +4 -4
  13. package/dist/components/inputs/WheelPicker.js +49 -0
  14. package/dist/components/inputs/WheelPicker.native.js +88 -0
  15. package/dist/components/inputs/WheelPicker.web.js +1 -0
  16. package/dist/components/inputs/dateWheelPicker/DateWheelPicker.js +24 -0
  17. package/dist/components/inputs/dateWheelPicker/DayWheelPicker.js +48 -0
  18. package/dist/components/inputs/dateWheelPicker/MonthWheelPicker.js +19 -0
  19. package/dist/components/inputs/dateWheelPicker/QuarterWheelPicker.js +61 -0
  20. package/dist/components/inputs/dateWheelPicker/WeekWheelPicker.js +66 -0
  21. package/dist/components/inputs/dateWheelPicker/YearWheelPicker.js +35 -0
  22. package/dist/components/inputs/index.js +5 -1
  23. package/dist/components/inputs/upload/Upload.native.js +60 -52
  24. package/dist/components/inputs/upload/useUploadState.js +11 -3
  25. package/dist/components/measurements/FeetInchesInput.js +91 -0
  26. package/dist/components/measurements/LengthInput.js +32 -0
  27. package/dist/components/measurements/LengthText.js +10 -0
  28. package/dist/components/measurements/MeasurementHandler.js +26 -0
  29. package/dist/components/measurements/WeightInput.js +25 -0
  30. package/dist/components/measurements/WeightText.js +10 -0
  31. package/dist/components/measurements/helpers/detectMeasurementSystem.js +15 -0
  32. package/dist/components/measurements/helpers/detectMeasurementSystem.native.js +9 -0
  33. package/dist/components/measurements/helpers/index.js +2 -0
  34. package/dist/components/measurements/helpers/length.js +112 -0
  35. package/dist/components/measurements/helpers/weight.js +56 -0
  36. package/dist/components/measurements/index.js +9 -0
  37. package/dist/components/measurements/useLengthFormatter.js +35 -0
  38. package/dist/components/measurements/useLocalInputValue.js +32 -0
  39. package/dist/components/measurements/useWeightFormatter.js +29 -0
  40. package/dist/components/routing/ReturnButton.js +20 -0
  41. package/dist/components/routing/ReturnButton.native.js +20 -0
  42. package/dist/components/routing/ReturnButton.web.js +2 -0
  43. package/dist/components/routing/ReturnLink.js +25 -0
  44. package/dist/components/routing/ReturnLink.native.js +25 -0
  45. package/dist/components/routing/ReturnLink.web.js +2 -0
  46. package/dist/components/routing/RoutedStepsContent.js +21 -0
  47. package/dist/components/routing/RoutedStepsContent.native.js +94 -0
  48. package/dist/components/routing/RoutedStepsContent.web.js +3 -0
  49. package/dist/components/routing/index.js +3 -0
  50. package/dist/components/state/StatePresenter.js +1 -1
  51. package/dist/components/steps/StepsHandler.js +2 -0
  52. package/dist/components/structure/TopBar.js +18 -16
  53. package/dist/components/theme/ThemePickerDrawer.js +1 -1
  54. package/dist/helpers/compress.js +61 -0
  55. package/dist/helpers/compress.native.js +49 -0
  56. package/dist/helpers/files.js +7 -0
  57. package/dist/helpers/files.native.js +55 -0
  58. package/dist/helpers/index.js +6 -1
  59. package/dist/helpers/media.js +4 -0
  60. package/dist/helpers/media.native.js +41 -0
  61. package/dist/helpers/numbers.js +13 -0
  62. package/dist/helpers/pickAssets.js +7 -0
  63. package/dist/helpers/pickAssets.native.js +66 -0
  64. package/dist/helpers/storage.js +17 -0
  65. package/dist/i18n/I18n.js +4 -4
  66. package/dist/index.js +1 -1
  67. package/dist/responsive/responsiveHooks.js +17 -0
  68. package/package.json +2 -14
  69. package/src/NekoUI.js +16 -13
  70. package/src/abstractions/WindowOverlay.js +3 -0
  71. package/src/abstractions/WindowOverlay.native.js +21 -0
  72. package/src/abstractions/helpers/storage.js +13 -3
  73. package/src/abstractions/helpers/storage.native.js +8 -0
  74. package/src/components/feedback/notifications/NotificationsHandler.js +12 -8
  75. package/src/components/index.js +2 -0
  76. package/src/components/inputs/DateInput.js +8 -4
  77. package/src/components/inputs/InputWrapper.js +1 -2
  78. package/src/components/inputs/NumberWheelInput.js +50 -0
  79. package/src/components/inputs/NumberWheelPicker.js +43 -0
  80. package/src/components/inputs/UploadInput.js +2 -2
  81. package/src/components/inputs/WheelPicker.js +49 -0
  82. package/src/components/inputs/WheelPicker.native.js +88 -0
  83. package/src/components/inputs/WheelPicker.web.js +1 -0
  84. package/src/components/inputs/dateWheelPicker/DateWheelPicker.js +24 -0
  85. package/src/components/inputs/dateWheelPicker/DayWheelPicker.js +48 -0
  86. package/src/components/inputs/dateWheelPicker/MonthWheelPicker.js +19 -0
  87. package/src/components/inputs/dateWheelPicker/QuarterWheelPicker.js +61 -0
  88. package/src/components/inputs/dateWheelPicker/WeekWheelPicker.js +66 -0
  89. package/src/components/inputs/dateWheelPicker/YearWheelPicker.js +35 -0
  90. package/src/components/inputs/index.js +4 -0
  91. package/src/components/inputs/upload/Upload.native.js +58 -50
  92. package/src/components/inputs/upload/useUploadState.js +11 -3
  93. package/src/components/measurements/FeetInchesInput.js +91 -0
  94. package/src/components/measurements/LengthInput.js +32 -0
  95. package/src/components/measurements/LengthText.js +10 -0
  96. package/src/components/measurements/MeasurementHandler.js +26 -0
  97. package/src/components/measurements/WeightInput.js +25 -0
  98. package/src/components/measurements/WeightText.js +10 -0
  99. package/src/components/measurements/helpers/detectMeasurementSystem.js +15 -0
  100. package/src/components/measurements/helpers/detectMeasurementSystem.native.js +9 -0
  101. package/src/components/measurements/helpers/index.js +2 -0
  102. package/src/components/measurements/helpers/length.js +112 -0
  103. package/src/components/measurements/helpers/weight.js +56 -0
  104. package/src/components/measurements/index.js +9 -0
  105. package/src/components/measurements/useLengthFormatter.js +35 -0
  106. package/src/components/measurements/useLocalInputValue.js +32 -0
  107. package/src/components/measurements/useWeightFormatter.js +29 -0
  108. package/src/components/routing/ReturnButton.js +20 -0
  109. package/src/components/routing/ReturnButton.native.js +20 -0
  110. package/src/components/routing/ReturnButton.web.js +2 -0
  111. package/src/components/routing/ReturnLink.js +25 -0
  112. package/src/components/routing/ReturnLink.native.js +25 -0
  113. package/src/components/routing/ReturnLink.web.js +2 -0
  114. package/src/components/routing/RoutedStepsContent.js +21 -0
  115. package/src/components/routing/RoutedStepsContent.native.js +94 -0
  116. package/src/components/routing/RoutedStepsContent.web.js +3 -0
  117. package/src/components/routing/index.js +3 -0
  118. package/src/components/state/StatePresenter.js +1 -1
  119. package/src/components/steps/StepsHandler.js +2 -0
  120. package/src/components/structure/TopBar.js +16 -14
  121. package/src/components/theme/ThemePickerDrawer.js +1 -1
  122. package/src/helpers/compress.js +61 -0
  123. package/src/helpers/compress.native.js +49 -0
  124. package/src/helpers/files.js +7 -0
  125. package/src/helpers/files.native.js +55 -0
  126. package/src/helpers/index.js +6 -1
  127. package/src/helpers/media.js +4 -0
  128. package/src/helpers/media.native.js +41 -0
  129. package/src/helpers/numbers.js +13 -0
  130. package/src/helpers/pickAssets.js +7 -0
  131. package/src/helpers/pickAssets.native.js +66 -0
  132. package/src/helpers/storage.js +17 -0
  133. package/src/i18n/I18n.js +2 -2
  134. package/src/index.js +1 -1
  135. package/src/responsive/responsiveHooks.js +17 -0
@@ -5,13 +5,11 @@ import { Icon } from '../../presentation/Icon'
5
5
  import { Link } from '../../actions/Link'
6
6
  import { Text } from '../../text/Text'
7
7
  import { View } from '../../structure/View'
8
+ import { compressAssets } from '../../../helpers/compress'
9
+ import { persistFile } from '../../../helpers/files'
10
+ import { pickFromCamera, pickFromLibrary } from '../../../helpers/pickAssets'
8
11
  import { useUploadState } from './useUploadState'
9
12
 
10
- let ImagePicker
11
- try {
12
- ImagePicker = require('expo-image-picker')
13
- } catch {}
14
-
15
13
  let DocumentPicker
16
14
  try {
17
15
  DocumentPicker = require('expo-document-picker')
@@ -28,17 +26,6 @@ function needsFilePicker(accept) {
28
26
  return parts.some((p) => !p.startsWith('image/'))
29
27
  }
30
28
 
31
- function normalizeImageResult(asset) {
32
- return {
33
- uri: asset.uri,
34
- name: asset.fileName || asset.uri.split('/').pop(),
35
- type: asset.mimeType || asset.type || 'image/jpeg',
36
- size: asset.fileSize || asset.filesize,
37
- width: asset.width,
38
- height: asset.height,
39
- }
40
- }
41
-
42
29
  function normalizeDocumentResult(doc) {
43
30
  return {
44
31
  uri: doc.uri,
@@ -56,53 +43,74 @@ function getMediaTypes(accept) {
56
43
  return undefined
57
44
  }
58
45
 
59
- export function Upload({ children, onChange, onUpload, value: valueProp, accept, multiple = false, maxCount, disabled = false, ...props }) {
46
+ export function Upload({ children, onChange, onUpload, value: valueProp, accept, multiple = false, maxCount, disabled = false, persistTo, saveToLibrary, compress = true, ...props }) {
60
47
  const { value, addFiles, remove } = useUploadState({ onUpload, onChange, multiple, maxCount, value: valueProp })
61
48
  const [open, setOpen] = useState(false)
62
49
 
50
+ const commitFiles = useCallback(
51
+ async (assets) => {
52
+ const compressOpts = typeof compress === 'object' ? compress : {}
53
+ const processed = compress !== false ? await compressAssets(assets, compressOpts) : assets
54
+ if (persistTo && !onUpload) {
55
+ addFiles(processed.map((a) => ({ ...a, uri: persistFile(a.uri, persistTo, { name: a.name }) })))
56
+ } else {
57
+ addFiles(processed)
58
+ }
59
+ },
60
+ [compress, persistTo, onUpload, addFiles]
61
+ )
62
+
63
63
  const handlePress = useCallback(() => {
64
64
  if (disabled) return
65
65
  setOpen(true)
66
66
  }, [disabled])
67
67
 
68
+ // NOTE: keep the drawer OPEN while presenting the native picker, and close it
69
+ // only after the picker returns. The drawer is a RN <Modal>; closing it first
70
+ // makes the picker present from a modal that's mid-dismiss (and then unmounts
71
+ // under the picker) -> iOS present/dismiss collision that freezes the screen on
72
+ // the second open. Closing in `finally` avoids any concurrent modal transition.
68
73
  const handleCamera = useCallback(async () => {
69
- setOpen(false)
70
- if (!ImagePicker) return
71
- const permission = await ImagePicker.requestCameraPermissionsAsync()
72
- if (!permission.granted) return
73
- const result = await ImagePicker.launchCameraAsync({
74
- allowsMultipleSelection: multiple,
75
- selectionLimit: maxCount || 0,
76
- mediaTypes: getMediaTypes(accept),
77
- })
78
- if (result.canceled) return
79
- addFiles(result.assets.map(normalizeImageResult))
80
- }, [multiple, maxCount, accept, addFiles])
74
+ try {
75
+ const result = await pickFromCamera({
76
+ multiple, maxCount, compress, saveToLibrary,
77
+ mediaTypes: getMediaTypes(accept),
78
+ persistTo: !onUpload ? persistTo : undefined,
79
+ })
80
+ if (multiple ? !result.length : !result) return
81
+ addFiles(multiple ? result : [result])
82
+ } finally {
83
+ setOpen(false)
84
+ }
85
+ }, [multiple, maxCount, accept, compress, saveToLibrary, onUpload, persistTo, addFiles])
81
86
 
82
87
  const handleLibrary = useCallback(async () => {
83
- setOpen(false)
84
- if (!ImagePicker) return
85
- const permission = await ImagePicker.requestMediaLibraryPermissionsAsync()
86
- if (!permission.granted) return
87
- const result = await ImagePicker.launchImageLibraryAsync({
88
- allowsMultipleSelection: multiple,
89
- selectionLimit: maxCount || 0,
90
- mediaTypes: getMediaTypes(accept),
91
- })
92
- if (result.canceled) return
93
- addFiles(result.assets.map(normalizeImageResult))
94
- }, [multiple, maxCount, accept, addFiles])
88
+ try {
89
+ const result = await pickFromLibrary({
90
+ multiple, maxCount, compress,
91
+ mediaTypes: getMediaTypes(accept),
92
+ persistTo: !onUpload ? persistTo : undefined,
93
+ })
94
+ if (multiple ? !result.length : !result) return
95
+ addFiles(multiple ? result : [result])
96
+ } finally {
97
+ setOpen(false)
98
+ }
99
+ }, [multiple, maxCount, accept, compress, onUpload, persistTo, addFiles])
95
100
 
96
101
  const handleFiles = useCallback(async () => {
97
- setOpen(false)
98
- if (!DocumentPicker) return
99
- const result = await DocumentPicker.getDocumentAsync({
100
- multiple,
101
- type: accept || '*/*',
102
- })
103
- if (result.canceled) return
104
- addFiles(result.assets.map(normalizeDocumentResult))
105
- }, [multiple, accept, addFiles])
102
+ if (!DocumentPicker) return setOpen(false)
103
+ try {
104
+ const result = await DocumentPicker.getDocumentAsync({
105
+ multiple,
106
+ type: accept || '*/*',
107
+ })
108
+ if (result.canceled) return
109
+ commitFiles(result.assets.map(normalizeDocumentResult))
110
+ } finally {
111
+ setOpen(false)
112
+ }
113
+ }, [multiple, accept, commitFiles])
106
114
 
107
115
  const showCamera = acceptsImages(accept)
108
116
  const showLibrary = acceptsImages(accept)
@@ -5,6 +5,13 @@ function toArray(val) {
5
5
  return Array.isArray(val) ? val : [val]
6
6
  }
7
7
 
8
+ // Stable identity for a file item. Newly picked files get an internal `_id`;
9
+ // externally-provided value items (e.g. persisted records) are keyed by their
10
+ // own `id`, so consumers never need to stamp `_id` themselves.
11
+ function keyOf(file) {
12
+ return file?._id ?? file?.id
13
+ }
14
+
8
15
  export function useUploadState({ onUpload, onChange, multiple, maxCount, value: valueProp }) {
9
16
  const [inFlight, setInFlight] = useState([])
10
17
  const [committed, setCommitted] = useState(null)
@@ -125,12 +132,13 @@ export function useUploadState({ onUpload, onChange, multiple, maxCount, value:
125
132
  const remove = useCallback(
126
133
  (file) => {
127
134
  if (!file) return
135
+ const key = keyOf(file)
128
136
 
129
- setInFlight((prev) => prev.filter((f) => f._id !== file._id))
137
+ setInFlight((prev) => prev.filter((f) => keyOf(f) !== key))
130
138
 
131
139
  const ext = externalArrayRef.current
132
- if (ext.some((f) => f._id === file._id)) {
133
- const next = ext.filter((f) => f._id !== file._id)
140
+ if (ext.some((f) => keyOf(f) === key)) {
141
+ const next = ext.filter((f) => keyOf(f) !== key)
134
142
  const val = multiple ? next : next[0] || null
135
143
  if (!isControlled) setCommitted(val)
136
144
  onChangeRef.current?.(val)
@@ -0,0 +1,91 @@
1
+ import React from 'react'
2
+
3
+ import { BottomDrawer } from '../modals/bottomDrawer'
4
+ import { LinkInput } from '../inputs/LinkInput'
5
+ import { NumberInput } from '../inputs'
6
+ import { View } from '../structure'
7
+ import { WheelPicker } from '../inputs/WheelPicker'
8
+ import { useResponsiveValue } from '../../responsive'
9
+
10
+ const USE_BOTTOM_DRAWER = { native: true, sm: true, md: true }
11
+
12
+ function formatFeetInches(value) {
13
+ const { feet = 0, inches = 0 } = value || {}
14
+ if (!value || (!feet && !inches)) return ''
15
+ return `${feet}'${inches}"`
16
+ }
17
+
18
+ function FeetInchesInline({ value, onChange, label, ...props }) {
19
+ const { feet, inches } = value || {}
20
+
21
+ return (
22
+ <View row gap="sm" toBottom>
23
+ <NumberInput
24
+ value={feet}
25
+ onChange={(newFeet) => onChange({ feet: newFeet, inches })}
26
+ min={0}
27
+ precision={0}
28
+ flex
29
+ label={label}
30
+ {...props}
31
+ suffix="ft"
32
+ />
33
+ <NumberInput
34
+ value={inches}
35
+ onChange={(newInches) => onChange({ feet, inches: newInches })}
36
+ min={0}
37
+ max={11}
38
+ precision={0}
39
+ flex
40
+ {...props}
41
+ suffix="in"
42
+ />
43
+ </View>
44
+ )
45
+ }
46
+
47
+ const FEET_OPTIONS = Array.from({ length: 9 }, (_, i) => ({ label: i, value: i }))
48
+ const INCH_OPTIONS = Array.from({ length: 12 }, (_, i) => ({ label: i, value: i }))
49
+
50
+ function FeetInchesDrawer({ value, onChange, label, ...props }) {
51
+ const [open, setOpen] = React.useState(false)
52
+ const { feet = 0, inches = 0 } = value || {}
53
+
54
+ return (
55
+ <>
56
+ <LinkInput
57
+ value={formatFeetInches(value)}
58
+ placeholder={label || props.placeholder}
59
+ onPress={() => setOpen(true)}
60
+ {...props}
61
+ />
62
+ <BottomDrawer open={open} onClose={() => setOpen(false)} snapPoints={[350]}>
63
+ <View row gap="sm" center flex padding="md">
64
+ <View flex>
65
+ <WheelPicker
66
+ options={FEET_OPTIONS}
67
+ value={feet}
68
+ suffix="ft"
69
+ onChange={(f) => onChange({ feet: f, inches })}
70
+ />
71
+ </View>
72
+ <View flex>
73
+ <WheelPicker
74
+ options={INCH_OPTIONS}
75
+ value={inches}
76
+ suffix="in"
77
+ onChange={(i) => onChange({ feet, inches: i })}
78
+ />
79
+ </View>
80
+ </View>
81
+ </BottomDrawer>
82
+ </>
83
+ )
84
+ }
85
+
86
+ export function FeetInchesInput(props) {
87
+ const shouldUseDrawer = useResponsiveValue(USE_BOTTOM_DRAWER)
88
+
89
+ if (shouldUseDrawer) return <FeetInchesDrawer {...props} />
90
+ return <FeetInchesInline {...props} />
91
+ }
@@ -0,0 +1,32 @@
1
+ import { NumberInput } from '../inputs'
2
+ import { fixedDecimals } from '../../helpers/numbers'
3
+ import { LENGTH_CONVERTERS, LENGTH_IMPERIAL_DEFAULTS } from './helpers/length'
4
+ import { useIsImperial } from './MeasurementHandler'
5
+ import { useLocalInputValue } from './useLocalInputValue'
6
+ import { FeetInchesInput } from './FeetInchesInput'
7
+
8
+ export function LengthInput({
9
+ value,
10
+ onChange,
11
+ measurementSystem,
12
+ metricPrecision = 'cm',
13
+ imperialPrecision,
14
+ ...props
15
+ }) {
16
+ const isImperial = useIsImperial(measurementSystem)
17
+ const impPrec = imperialPrecision || LENGTH_IMPERIAL_DEFAULTS[metricPrecision] || 'ft+in'
18
+ const converter = isImperial ? LENGTH_CONVERTERS[metricPrecision]?.[impPrec] : null
19
+ const isFtIn = isImperial && impPrec === 'ft+in' && !!converter
20
+
21
+ const Input = isFtIn ? FeetInchesInput : NumberInput
22
+ const suffix = isFtIn ? false : converter ? impPrec : metricPrecision
23
+
24
+ let formattedValue
25
+ if (isFtIn) formattedValue = converter.to(value)
26
+ else if (converter) formattedValue = fixedDecimals(converter.to(value))
27
+ else formattedValue = fixedDecimals(value)
28
+
29
+ const [localValue, handleChange] = useLocalInputValue({ value, formattedValue, suffix, onChange, converter })
30
+
31
+ return <Input {...props} value={localValue} onChange={handleChange} suffix={suffix} />
32
+ }
@@ -0,0 +1,10 @@
1
+ import { Text } from '../text'
2
+ import { useLengthFormatter } from './useLengthFormatter'
3
+
4
+ export function LengthText({ value, measurementSystem, metricPrecision, imperialPrecision, withoutSuffix, ...props }) {
5
+ const format = useLengthFormatter({ measurementSystem, metricPrecision, imperialPrecision, withoutSuffix })
6
+
7
+ if (!value && value !== 0) return false
8
+
9
+ return <Text {...props}>{format(value)}</Text>
10
+ }
@@ -0,0 +1,26 @@
1
+ import React from 'react'
2
+
3
+ import { detectMeasurementSystem } from './helpers/detectMeasurementSystem'
4
+
5
+ const MeasurementContext = React.createContext(null)
6
+
7
+ export function MeasurementHandler({ children, measurementSystem }) {
8
+ const value = React.useMemo(() => ({ measurementSystem }), [measurementSystem])
9
+
10
+ return <MeasurementContext.Provider value={value}>{children}</MeasurementContext.Provider>
11
+ }
12
+
13
+ export function useMeasurementSystem(override) {
14
+ const ctx = React.useContext(MeasurementContext)
15
+
16
+ return React.useMemo(() => {
17
+ if (override) return override
18
+ if (ctx?.measurementSystem) return ctx.measurementSystem
19
+ return detectMeasurementSystem()
20
+ }, [override, ctx?.measurementSystem])
21
+ }
22
+
23
+ export function useIsImperial(override) {
24
+ const system = useMeasurementSystem(override)
25
+ return system === 'imperial'
26
+ }
@@ -0,0 +1,25 @@
1
+ import { NumberInput } from '../inputs'
2
+ import { fixedDecimals } from '../../helpers/numbers'
3
+ import { WEIGHT_CONVERTERS, WEIGHT_IMPERIAL_DEFAULTS } from './helpers/weight'
4
+ import { useIsImperial } from './MeasurementHandler'
5
+ import { useLocalInputValue } from './useLocalInputValue'
6
+
7
+ export function WeightInput({
8
+ value,
9
+ onChange,
10
+ measurementSystem,
11
+ metricPrecision = 'kg',
12
+ imperialPrecision,
13
+ ...props
14
+ }) {
15
+ const isImperial = useIsImperial(measurementSystem)
16
+ const impPrec = imperialPrecision || WEIGHT_IMPERIAL_DEFAULTS[metricPrecision] || 'lbs'
17
+ const converter = isImperial ? WEIGHT_CONVERTERS[metricPrecision]?.[impPrec] : null
18
+
19
+ const suffix = converter ? impPrec : metricPrecision
20
+ const formattedValue = converter ? fixedDecimals(converter.to(value)) : fixedDecimals(value)
21
+
22
+ const [localValue, handleChange] = useLocalInputValue({ value, formattedValue, suffix, onChange, converter })
23
+
24
+ return <NumberInput {...props} value={localValue} onChange={handleChange} suffix={suffix} />
25
+ }
@@ -0,0 +1,10 @@
1
+ import { Text } from '../text'
2
+ import { useWeightFormatter } from './useWeightFormatter'
3
+
4
+ export function WeightText({ value, measurementSystem, metricPrecision, imperialPrecision, withoutSuffix, ...props }) {
5
+ const format = useWeightFormatter({ measurementSystem, metricPrecision, imperialPrecision, withoutSuffix })
6
+
7
+ if (!value && value !== 0) return false
8
+
9
+ return <Text {...props}>{format(value)}</Text>
10
+ }
@@ -0,0 +1,15 @@
1
+ export function detectMeasurementSystem() {
2
+ try {
3
+ const locale = new Intl.Locale(navigator.language)
4
+ const info = locale.textInfo ?? locale.getTextInfo?.()
5
+ const ms = locale.measurementSystem ?? info?.measurementSystem
6
+ if (ms) return ['us', 'imperial'].includes(ms) ? 'imperial' : 'metric'
7
+
8
+ // Fallback: only US, Myanmar, Liberia use imperial
9
+ const lang = navigator.language || ''
10
+ if (lang.startsWith('en-US') || lang.startsWith('my-MM') || lang.startsWith('en-LR')) {
11
+ return 'imperial'
12
+ }
13
+ } catch {}
14
+ return 'metric'
15
+ }
@@ -0,0 +1,9 @@
1
+ export function detectMeasurementSystem() {
2
+ try {
3
+ const Localization = require('expo-localization')
4
+ const locales = Localization.getLocales()
5
+ const ms = locales?.[0]?.measurementSystem
6
+ if (ms) return ['us', 'imperial'].includes(ms) ? 'imperial' : 'metric'
7
+ } catch {}
8
+ return 'metric'
9
+ }
@@ -0,0 +1,2 @@
1
+ export * from './length'
2
+ export * from './weight'
@@ -0,0 +1,112 @@
1
+ const CM_TO_INCH = 0.393701
2
+ const CM_TO_FOOT = 0.0328084
3
+ const CM_PER_M = 100
4
+ const CM_PER_KM = 100000
5
+ const MI_TO_KM = 1.60934
6
+
7
+ export function cmToIn(cm) {
8
+ if (!cm) return cm
9
+ return cm * CM_TO_INCH
10
+ }
11
+
12
+ export function inToCm(inches) {
13
+ if (!inches) return inches
14
+ return inches / CM_TO_INCH
15
+ }
16
+
17
+ export function cmToFt(cm) {
18
+ if (!cm) return cm
19
+ return cm * CM_TO_FOOT
20
+ }
21
+
22
+ export function ftToCm(feet) {
23
+ if (!feet) return feet
24
+ return feet / CM_TO_FOOT
25
+ }
26
+
27
+ export function cmToFtIn(cm) {
28
+ if (!cm) return false
29
+ const totalInches = cmToIn(cm)
30
+ let feet = Math.floor(totalInches / 12)
31
+ let inches = Math.round(totalInches % 12)
32
+ if (inches === 12) {
33
+ feet += 1
34
+ inches = 0
35
+ }
36
+ return { feet, inches }
37
+ }
38
+
39
+ export function ftInToCm(value) {
40
+ if (!value) return false
41
+ const { feet, inches } = value || {}
42
+ const totalInches = (feet || 0) * 12 + (inches || 0)
43
+ return inToCm(totalInches)
44
+ }
45
+
46
+ export function cmToM(cm) {
47
+ if (!cm) return cm
48
+ return cm / CM_PER_M
49
+ }
50
+
51
+ export function mToCm(m) {
52
+ if (!m) return m
53
+ return m * CM_PER_M
54
+ }
55
+
56
+ export function mToFt(m) {
57
+ if (!m) return m
58
+ return cmToFt(m * CM_PER_M)
59
+ }
60
+
61
+ export function ftToM(ft) {
62
+ if (!ft) return ft
63
+ return ftToCm(ft) / CM_PER_M
64
+ }
65
+
66
+ export function mToFtIn(m) {
67
+ if (!m) return false
68
+ return cmToFtIn(m * CM_PER_M)
69
+ }
70
+
71
+ export function ftInToM(value) {
72
+ if (!value) return false
73
+ return ftInToCm(value) / CM_PER_M
74
+ }
75
+
76
+ export function mToIn(m) {
77
+ if (!m) return m
78
+ return cmToIn(m * CM_PER_M)
79
+ }
80
+
81
+ export function inToM(inches) {
82
+ if (!inches) return inches
83
+ return inToCm(inches) / CM_PER_M
84
+ }
85
+
86
+ export function kmToMi(km) {
87
+ if (!km) return km
88
+ return km / MI_TO_KM
89
+ }
90
+
91
+ export function miToKm(mi) {
92
+ if (!mi) return mi
93
+ return mi * MI_TO_KM
94
+ }
95
+
96
+ export const LENGTH_CONVERTERS = {
97
+ cm: {
98
+ 'ft+in': { to: cmToFtIn, from: ftInToCm },
99
+ in: { to: cmToIn, from: inToCm },
100
+ ft: { to: cmToFt, from: ftToCm },
101
+ },
102
+ m: {
103
+ ft: { to: mToFt, from: ftToM },
104
+ 'ft+in': { to: mToFtIn, from: ftInToM },
105
+ in: { to: mToIn, from: inToM },
106
+ },
107
+ km: {
108
+ mi: { to: kmToMi, from: miToKm },
109
+ },
110
+ }
111
+
112
+ export const LENGTH_IMPERIAL_DEFAULTS = { cm: 'ft+in', m: 'ft', km: 'mi' }
@@ -0,0 +1,56 @@
1
+ const KG_TO_LB = 2.20462
2
+ const OZ_PER_LB = 16
3
+ const G_PER_KG = 1000
4
+
5
+ export function kgToLbs(kg) {
6
+ if (!kg) return kg
7
+ return kg * KG_TO_LB
8
+ }
9
+
10
+ export function lbsToKg(lbs) {
11
+ if (!lbs) return lbs
12
+ return lbs / KG_TO_LB
13
+ }
14
+
15
+ export function kgToOz(kg) {
16
+ if (!kg) return kg
17
+ return kg * KG_TO_LB * OZ_PER_LB
18
+ }
19
+
20
+ export function ozToKg(oz) {
21
+ if (!oz) return oz
22
+ return oz / (KG_TO_LB * OZ_PER_LB)
23
+ }
24
+
25
+ export function gToOz(g) {
26
+ if (!g) return g
27
+ return kgToOz(g / G_PER_KG)
28
+ }
29
+
30
+ export function ozToG(oz) {
31
+ if (!oz) return oz
32
+ return ozToKg(oz) * G_PER_KG
33
+ }
34
+
35
+ export function gToLbs(g) {
36
+ if (!g) return g
37
+ return kgToLbs(g / G_PER_KG)
38
+ }
39
+
40
+ export function lbsToG(lbs) {
41
+ if (!lbs) return lbs
42
+ return lbsToKg(lbs) * G_PER_KG
43
+ }
44
+
45
+ export const WEIGHT_CONVERTERS = {
46
+ kg: {
47
+ lbs: { to: kgToLbs, from: lbsToKg },
48
+ oz: { to: kgToOz, from: ozToKg },
49
+ },
50
+ g: {
51
+ oz: { to: gToOz, from: ozToG },
52
+ lbs: { to: gToLbs, from: lbsToG },
53
+ },
54
+ }
55
+
56
+ export const WEIGHT_IMPERIAL_DEFAULTS = { kg: 'lbs', g: 'oz' }
@@ -0,0 +1,9 @@
1
+ export * from './helpers'
2
+ export * from './MeasurementHandler'
3
+ export * from './useLengthFormatter'
4
+ export * from './useWeightFormatter'
5
+ export * from './LengthText'
6
+ export * from './WeightText'
7
+ export * from './FeetInchesInput'
8
+ export * from './LengthInput'
9
+ export * from './WeightInput'
@@ -0,0 +1,35 @@
1
+ import { LENGTH_CONVERTERS, LENGTH_IMPERIAL_DEFAULTS } from './helpers/length'
2
+ import { fixedDecimals } from '../../helpers/numbers'
3
+ import { useIsImperial } from './MeasurementHandler'
4
+
5
+ export function useLengthFormatter({
6
+ measurementSystem,
7
+ metricPrecision = 'cm',
8
+ imperialPrecision,
9
+ withoutSuffix,
10
+ } = {}) {
11
+ const isImperial = useIsImperial(measurementSystem)
12
+ const impPrec = imperialPrecision || LENGTH_IMPERIAL_DEFAULTS[metricPrecision] || 'ft+in'
13
+
14
+ return (value) => {
15
+ if (!value && value !== 0) return null
16
+
17
+ if (isImperial) {
18
+ const converter = LENGTH_CONVERTERS[metricPrecision]?.[impPrec]
19
+ if (!converter) return `${fixedDecimals(value)} ${metricPrecision}`
20
+
21
+ const converted = converter.to(value)
22
+
23
+ if (impPrec === 'ft+in') {
24
+ const v = typeof converted === 'object' ? converted : { feet: 0, inches: 0 }
25
+ return `${v.feet}'${v.inches}"`
26
+ }
27
+
28
+ if (withoutSuffix) return fixedDecimals(converted)
29
+ return `${fixedDecimals(converted)} ${impPrec}`
30
+ }
31
+
32
+ if (withoutSuffix) return fixedDecimals(value)
33
+ return `${fixedDecimals(value)} ${metricPrecision}`
34
+ }
35
+ }
@@ -0,0 +1,32 @@
1
+ import React from 'react'
2
+
3
+ // Local display state for measurement inputs.
4
+ // Resyncs when the unit changes or the value is changed externally,
5
+ // without clobbering what the user is typing (their own onChange echoes back).
6
+ export function useLocalInputValue({ value, formattedValue, suffix, onChange, converter }) {
7
+ const [localValue, setLocalValue] = React.useState(formattedValue)
8
+ const [prevSuffix, setPrevSuffix] = React.useState(suffix)
9
+ const lastEmitted = React.useRef(value)
10
+
11
+ if (suffix !== prevSuffix) {
12
+ setPrevSuffix(suffix)
13
+ setLocalValue(formattedValue)
14
+ lastEmitted.current = value
15
+ } else if (value !== lastEmitted.current) {
16
+ lastEmitted.current = value
17
+ setLocalValue(formattedValue)
18
+ }
19
+
20
+ function handleChange(newValue) {
21
+ setLocalValue(newValue)
22
+ if (!newValue && newValue !== 0) {
23
+ lastEmitted.current = newValue
24
+ return onChange(newValue)
25
+ }
26
+ const converted = converter ? converter.from(newValue) : newValue
27
+ lastEmitted.current = converted
28
+ onChange(converted)
29
+ }
30
+
31
+ return [localValue, handleChange]
32
+ }