@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.
- package/dist/NekoUI.js +12 -9
- package/dist/abstractions/WindowOverlay.js +3 -0
- package/dist/abstractions/WindowOverlay.native.js +21 -0
- package/dist/abstractions/helpers/storage.js +14 -4
- package/dist/abstractions/helpers/storage.native.js +9 -1
- package/dist/components/feedback/notifications/NotificationsHandler.js +10 -6
- package/dist/components/index.js +3 -1
- package/dist/components/inputs/DateInput.js +10 -6
- package/dist/components/inputs/InputWrapper.js +2 -3
- package/dist/components/inputs/NumberWheelInput.js +50 -0
- package/dist/components/inputs/NumberWheelPicker.js +43 -0
- package/dist/components/inputs/UploadInput.js +4 -4
- package/dist/components/inputs/WheelPicker.js +49 -0
- package/dist/components/inputs/WheelPicker.native.js +88 -0
- package/dist/components/inputs/WheelPicker.web.js +1 -0
- package/dist/components/inputs/dateWheelPicker/DateWheelPicker.js +24 -0
- package/dist/components/inputs/dateWheelPicker/DayWheelPicker.js +48 -0
- package/dist/components/inputs/dateWheelPicker/MonthWheelPicker.js +19 -0
- package/dist/components/inputs/dateWheelPicker/QuarterWheelPicker.js +61 -0
- package/dist/components/inputs/dateWheelPicker/WeekWheelPicker.js +66 -0
- package/dist/components/inputs/dateWheelPicker/YearWheelPicker.js +35 -0
- package/dist/components/inputs/index.js +5 -1
- package/dist/components/inputs/upload/Upload.native.js +60 -52
- package/dist/components/inputs/upload/useUploadState.js +11 -3
- package/dist/components/measurements/FeetInchesInput.js +91 -0
- package/dist/components/measurements/LengthInput.js +32 -0
- package/dist/components/measurements/LengthText.js +10 -0
- package/dist/components/measurements/MeasurementHandler.js +26 -0
- package/dist/components/measurements/WeightInput.js +25 -0
- package/dist/components/measurements/WeightText.js +10 -0
- package/dist/components/measurements/helpers/detectMeasurementSystem.js +15 -0
- package/dist/components/measurements/helpers/detectMeasurementSystem.native.js +9 -0
- package/dist/components/measurements/helpers/index.js +2 -0
- package/dist/components/measurements/helpers/length.js +112 -0
- package/dist/components/measurements/helpers/weight.js +56 -0
- package/dist/components/measurements/index.js +9 -0
- package/dist/components/measurements/useLengthFormatter.js +35 -0
- package/dist/components/measurements/useLocalInputValue.js +32 -0
- package/dist/components/measurements/useWeightFormatter.js +29 -0
- package/dist/components/routing/ReturnButton.js +20 -0
- package/dist/components/routing/ReturnButton.native.js +20 -0
- package/dist/components/routing/ReturnButton.web.js +2 -0
- package/dist/components/routing/ReturnLink.js +25 -0
- package/dist/components/routing/ReturnLink.native.js +25 -0
- package/dist/components/routing/ReturnLink.web.js +2 -0
- package/dist/components/routing/RoutedStepsContent.js +21 -0
- package/dist/components/routing/RoutedStepsContent.native.js +94 -0
- package/dist/components/routing/RoutedStepsContent.web.js +3 -0
- package/dist/components/routing/index.js +3 -0
- package/dist/components/state/StatePresenter.js +1 -1
- package/dist/components/steps/StepsHandler.js +2 -0
- package/dist/components/structure/TopBar.js +18 -16
- package/dist/components/theme/ThemePickerDrawer.js +1 -1
- package/dist/helpers/compress.js +61 -0
- package/dist/helpers/compress.native.js +49 -0
- package/dist/helpers/files.js +7 -0
- package/dist/helpers/files.native.js +55 -0
- package/dist/helpers/index.js +6 -1
- package/dist/helpers/media.js +4 -0
- package/dist/helpers/media.native.js +41 -0
- package/dist/helpers/numbers.js +13 -0
- package/dist/helpers/pickAssets.js +7 -0
- package/dist/helpers/pickAssets.native.js +66 -0
- package/dist/helpers/storage.js +17 -0
- package/dist/i18n/I18n.js +4 -4
- package/dist/index.js +1 -1
- package/dist/responsive/responsiveHooks.js +17 -0
- package/package.json +2 -14
- package/src/NekoUI.js +16 -13
- package/src/abstractions/WindowOverlay.js +3 -0
- package/src/abstractions/WindowOverlay.native.js +21 -0
- package/src/abstractions/helpers/storage.js +13 -3
- package/src/abstractions/helpers/storage.native.js +8 -0
- package/src/components/feedback/notifications/NotificationsHandler.js +12 -8
- package/src/components/index.js +2 -0
- package/src/components/inputs/DateInput.js +8 -4
- package/src/components/inputs/InputWrapper.js +1 -2
- package/src/components/inputs/NumberWheelInput.js +50 -0
- package/src/components/inputs/NumberWheelPicker.js +43 -0
- package/src/components/inputs/UploadInput.js +2 -2
- package/src/components/inputs/WheelPicker.js +49 -0
- package/src/components/inputs/WheelPicker.native.js +88 -0
- package/src/components/inputs/WheelPicker.web.js +1 -0
- package/src/components/inputs/dateWheelPicker/DateWheelPicker.js +24 -0
- package/src/components/inputs/dateWheelPicker/DayWheelPicker.js +48 -0
- package/src/components/inputs/dateWheelPicker/MonthWheelPicker.js +19 -0
- package/src/components/inputs/dateWheelPicker/QuarterWheelPicker.js +61 -0
- package/src/components/inputs/dateWheelPicker/WeekWheelPicker.js +66 -0
- package/src/components/inputs/dateWheelPicker/YearWheelPicker.js +35 -0
- package/src/components/inputs/index.js +4 -0
- package/src/components/inputs/upload/Upload.native.js +58 -50
- package/src/components/inputs/upload/useUploadState.js +11 -3
- package/src/components/measurements/FeetInchesInput.js +91 -0
- package/src/components/measurements/LengthInput.js +32 -0
- package/src/components/measurements/LengthText.js +10 -0
- package/src/components/measurements/MeasurementHandler.js +26 -0
- package/src/components/measurements/WeightInput.js +25 -0
- package/src/components/measurements/WeightText.js +10 -0
- package/src/components/measurements/helpers/detectMeasurementSystem.js +15 -0
- package/src/components/measurements/helpers/detectMeasurementSystem.native.js +9 -0
- package/src/components/measurements/helpers/index.js +2 -0
- package/src/components/measurements/helpers/length.js +112 -0
- package/src/components/measurements/helpers/weight.js +56 -0
- package/src/components/measurements/index.js +9 -0
- package/src/components/measurements/useLengthFormatter.js +35 -0
- package/src/components/measurements/useLocalInputValue.js +32 -0
- package/src/components/measurements/useWeightFormatter.js +29 -0
- package/src/components/routing/ReturnButton.js +20 -0
- package/src/components/routing/ReturnButton.native.js +20 -0
- package/src/components/routing/ReturnButton.web.js +2 -0
- package/src/components/routing/ReturnLink.js +25 -0
- package/src/components/routing/ReturnLink.native.js +25 -0
- package/src/components/routing/ReturnLink.web.js +2 -0
- package/src/components/routing/RoutedStepsContent.js +21 -0
- package/src/components/routing/RoutedStepsContent.native.js +94 -0
- package/src/components/routing/RoutedStepsContent.web.js +3 -0
- package/src/components/routing/index.js +3 -0
- package/src/components/state/StatePresenter.js +1 -1
- package/src/components/steps/StepsHandler.js +2 -0
- package/src/components/structure/TopBar.js +16 -14
- package/src/components/theme/ThemePickerDrawer.js +1 -1
- package/src/helpers/compress.js +61 -0
- package/src/helpers/compress.native.js +49 -0
- package/src/helpers/files.js +7 -0
- package/src/helpers/files.native.js +55 -0
- package/src/helpers/index.js +6 -1
- package/src/helpers/media.js +4 -0
- package/src/helpers/media.native.js +41 -0
- package/src/helpers/numbers.js +13 -0
- package/src/helpers/pickAssets.js +7 -0
- package/src/helpers/pickAssets.native.js +66 -0
- package/src/helpers/storage.js +17 -0
- package/src/i18n/I18n.js +2 -2
- package/src/index.js +1 -1
- 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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
137
|
+
setInFlight((prev) => prev.filter((f) => keyOf(f) !== key))
|
|
130
138
|
|
|
131
139
|
const ext = externalArrayRef.current
|
|
132
|
-
if (ext.some((f) => f
|
|
133
|
-
const next = ext.filter((f) => f
|
|
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,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
|
+
}
|