@neko-os/ui 0.5.3 → 0.6.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 (159) hide show
  1. package/dist/NekoUI.js +12 -9
  2. package/dist/abstractions/Image.native.js +1 -1
  3. package/dist/abstractions/Image.web.js +1 -1
  4. package/dist/abstractions/WindowOverlay.js +3 -0
  5. package/dist/abstractions/WindowOverlay.native.js +21 -0
  6. package/dist/abstractions/helpers/storage.js +14 -4
  7. package/dist/abstractions/helpers/storage.native.js +9 -1
  8. package/dist/components/feedback/notifications/NotificationsHandler.js +10 -6
  9. package/dist/components/form/useNewForm.js +2 -0
  10. package/dist/components/index.js +3 -1
  11. package/dist/components/inputs/DateInput.js +10 -6
  12. package/dist/components/inputs/InputWrapper.js +11 -11
  13. package/dist/components/inputs/NumberWheelInput.js +50 -0
  14. package/dist/components/inputs/NumberWheelPicker.js +43 -0
  15. package/dist/components/inputs/SegmentedPicker.js +3 -2
  16. package/dist/components/inputs/UploadInput.js +4 -4
  17. package/dist/components/inputs/WheelPicker.js +49 -0
  18. package/dist/components/inputs/WheelPicker.native.js +88 -0
  19. package/dist/components/inputs/WheelPicker.web.js +1 -0
  20. package/dist/components/inputs/dateWheelPicker/DateWheelPicker.js +24 -0
  21. package/dist/components/inputs/dateWheelPicker/DayWheelPicker.js +48 -0
  22. package/dist/components/inputs/dateWheelPicker/MonthWheelPicker.js +19 -0
  23. package/dist/components/inputs/dateWheelPicker/QuarterWheelPicker.js +61 -0
  24. package/dist/components/inputs/dateWheelPicker/WeekWheelPicker.js +66 -0
  25. package/dist/components/inputs/dateWheelPicker/YearWheelPicker.js +35 -0
  26. package/dist/components/inputs/index.js +5 -1
  27. package/dist/components/inputs/upload/Upload.native.js +60 -52
  28. package/dist/components/inputs/upload/useUploadState.js +11 -3
  29. package/dist/components/measurements/FeetInchesInput.js +91 -0
  30. package/dist/components/measurements/LengthInput.js +32 -0
  31. package/dist/components/measurements/LengthText.js +10 -0
  32. package/dist/components/measurements/MeasurementHandler.js +26 -0
  33. package/dist/components/measurements/WeightInput.js +25 -0
  34. package/dist/components/measurements/WeightText.js +10 -0
  35. package/dist/components/measurements/helpers/detectMeasurementSystem.js +15 -0
  36. package/dist/components/measurements/helpers/detectMeasurementSystem.native.js +9 -0
  37. package/dist/components/measurements/helpers/index.js +2 -0
  38. package/dist/components/measurements/helpers/length.js +112 -0
  39. package/dist/components/measurements/helpers/weight.js +56 -0
  40. package/dist/components/measurements/index.js +9 -0
  41. package/dist/components/measurements/useLengthFormatter.js +35 -0
  42. package/dist/components/measurements/useLocalInputValue.js +32 -0
  43. package/dist/components/measurements/useWeightFormatter.js +29 -0
  44. package/dist/components/presentation/Avatar.js +3 -3
  45. package/dist/components/routing/ReturnButton.js +20 -0
  46. package/dist/components/routing/ReturnButton.native.js +20 -0
  47. package/dist/components/routing/ReturnButton.web.js +2 -0
  48. package/dist/components/routing/ReturnLink.js +25 -0
  49. package/dist/components/routing/ReturnLink.native.js +25 -0
  50. package/dist/components/routing/ReturnLink.web.js +2 -0
  51. package/dist/components/routing/RoutedStepsContent.js +21 -0
  52. package/dist/components/routing/RoutedStepsContent.native.js +94 -0
  53. package/dist/components/routing/RoutedStepsContent.web.js +3 -0
  54. package/dist/components/routing/index.js +3 -0
  55. package/dist/components/state/StatePresenter.js +1 -1
  56. package/dist/components/steps/StepsHandler.js +2 -0
  57. package/dist/components/structure/TopBar.js +18 -16
  58. package/dist/components/theme/ThemePickerDrawer.js +1 -1
  59. package/dist/helpers/compress.js +61 -0
  60. package/dist/helpers/compress.native.js +49 -0
  61. package/dist/helpers/files.js +7 -0
  62. package/dist/helpers/files.native.js +55 -0
  63. package/dist/helpers/index.js +6 -1
  64. package/dist/helpers/media.js +4 -0
  65. package/dist/helpers/media.native.js +41 -0
  66. package/dist/helpers/numbers.js +13 -0
  67. package/dist/helpers/pickAssets.js +7 -0
  68. package/dist/helpers/pickAssets.native.js +66 -0
  69. package/dist/helpers/storage.js +17 -0
  70. package/dist/i18n/I18n.js +4 -4
  71. package/dist/index.js +1 -1
  72. package/dist/modifiers/flex.js +8 -3
  73. package/dist/responsive/responsiveHooks.js +14 -0
  74. package/dist/theme/default/blackTheme.js +3 -1
  75. package/dist/theme/default/cyberpunkTheme.js +3 -1
  76. package/dist/theme/default/darkTheme.js +3 -1
  77. package/dist/theme/default/hackerTheme.js +3 -1
  78. package/dist/theme/default/lightTheme.js +3 -1
  79. package/dist/theme/default/paperTheme.js +3 -1
  80. package/package.json +2 -14
  81. package/src/NekoUI.js +16 -13
  82. package/src/abstractions/Image.native.js +1 -1
  83. package/src/abstractions/Image.web.js +1 -1
  84. package/src/abstractions/WindowOverlay.js +3 -0
  85. package/src/abstractions/WindowOverlay.native.js +21 -0
  86. package/src/abstractions/helpers/storage.js +13 -3
  87. package/src/abstractions/helpers/storage.native.js +8 -0
  88. package/src/components/feedback/notifications/NotificationsHandler.js +12 -8
  89. package/src/components/form/useNewForm.js +2 -0
  90. package/src/components/index.js +2 -0
  91. package/src/components/inputs/DateInput.js +8 -4
  92. package/src/components/inputs/InputWrapper.js +3 -3
  93. package/src/components/inputs/NumberWheelInput.js +50 -0
  94. package/src/components/inputs/NumberWheelPicker.js +43 -0
  95. package/src/components/inputs/SegmentedPicker.js +2 -1
  96. package/src/components/inputs/UploadInput.js +2 -2
  97. package/src/components/inputs/WheelPicker.js +49 -0
  98. package/src/components/inputs/WheelPicker.native.js +88 -0
  99. package/src/components/inputs/WheelPicker.web.js +1 -0
  100. package/src/components/inputs/dateWheelPicker/DateWheelPicker.js +24 -0
  101. package/src/components/inputs/dateWheelPicker/DayWheelPicker.js +48 -0
  102. package/src/components/inputs/dateWheelPicker/MonthWheelPicker.js +19 -0
  103. package/src/components/inputs/dateWheelPicker/QuarterWheelPicker.js +61 -0
  104. package/src/components/inputs/dateWheelPicker/WeekWheelPicker.js +66 -0
  105. package/src/components/inputs/dateWheelPicker/YearWheelPicker.js +35 -0
  106. package/src/components/inputs/index.js +4 -0
  107. package/src/components/inputs/upload/Upload.native.js +58 -50
  108. package/src/components/inputs/upload/useUploadState.js +11 -3
  109. package/src/components/measurements/FeetInchesInput.js +91 -0
  110. package/src/components/measurements/LengthInput.js +32 -0
  111. package/src/components/measurements/LengthText.js +10 -0
  112. package/src/components/measurements/MeasurementHandler.js +26 -0
  113. package/src/components/measurements/WeightInput.js +25 -0
  114. package/src/components/measurements/WeightText.js +10 -0
  115. package/src/components/measurements/helpers/detectMeasurementSystem.js +15 -0
  116. package/src/components/measurements/helpers/detectMeasurementSystem.native.js +9 -0
  117. package/src/components/measurements/helpers/index.js +2 -0
  118. package/src/components/measurements/helpers/length.js +112 -0
  119. package/src/components/measurements/helpers/weight.js +56 -0
  120. package/src/components/measurements/index.js +9 -0
  121. package/src/components/measurements/useLengthFormatter.js +35 -0
  122. package/src/components/measurements/useLocalInputValue.js +32 -0
  123. package/src/components/measurements/useWeightFormatter.js +29 -0
  124. package/src/components/presentation/Avatar.js +2 -2
  125. package/src/components/routing/ReturnButton.js +20 -0
  126. package/src/components/routing/ReturnButton.native.js +20 -0
  127. package/src/components/routing/ReturnButton.web.js +2 -0
  128. package/src/components/routing/ReturnLink.js +25 -0
  129. package/src/components/routing/ReturnLink.native.js +25 -0
  130. package/src/components/routing/ReturnLink.web.js +2 -0
  131. package/src/components/routing/RoutedStepsContent.js +21 -0
  132. package/src/components/routing/RoutedStepsContent.native.js +94 -0
  133. package/src/components/routing/RoutedStepsContent.web.js +3 -0
  134. package/src/components/routing/index.js +3 -0
  135. package/src/components/state/StatePresenter.js +1 -1
  136. package/src/components/steps/StepsHandler.js +2 -0
  137. package/src/components/structure/TopBar.js +16 -14
  138. package/src/components/theme/ThemePickerDrawer.js +1 -1
  139. package/src/helpers/compress.js +61 -0
  140. package/src/helpers/compress.native.js +49 -0
  141. package/src/helpers/files.js +7 -0
  142. package/src/helpers/files.native.js +55 -0
  143. package/src/helpers/index.js +6 -1
  144. package/src/helpers/media.js +4 -0
  145. package/src/helpers/media.native.js +41 -0
  146. package/src/helpers/numbers.js +13 -0
  147. package/src/helpers/pickAssets.js +7 -0
  148. package/src/helpers/pickAssets.native.js +66 -0
  149. package/src/helpers/storage.js +17 -0
  150. package/src/i18n/I18n.js +2 -2
  151. package/src/index.js +1 -1
  152. package/src/modifiers/flex.js +7 -2
  153. package/src/responsive/responsiveHooks.js +14 -0
  154. package/src/theme/default/blackTheme.js +2 -0
  155. package/src/theme/default/cyberpunkTheme.js +2 -0
  156. package/src/theme/default/darkTheme.js +2 -0
  157. package/src/theme/default/hackerTheme.js +2 -0
  158. package/src/theme/default/lightTheme.js +2 -0
  159. package/src/theme/default/paperTheme.js +2 -0
@@ -0,0 +1,61 @@
1
+ import { useMemo } from 'react'
2
+ import dayjs from 'dayjs'
3
+
4
+ import { ClearLink } from '../../actions/ClearLink'
5
+ import { View } from '../../structure'
6
+ import { WheelPicker } from '../WheelPicker'
7
+
8
+ const QUARTERS = [
9
+ { value: 1, label: 'Q1' },
10
+ { value: 2, label: 'Q2' },
11
+ { value: 3, label: 'Q3' },
12
+ { value: 4, label: 'Q4' },
13
+ ]
14
+
15
+ function getYearOptions(min, max) {
16
+ const minYear = min ? dayjs(min).year() : dayjs().year() - 100
17
+ const maxYear = max ? dayjs(max).year() : dayjs().year() + 100
18
+ const options = []
19
+ for (let y = minYear; y <= maxYear; y++) options.push({ value: y, label: String(y) })
20
+ return options
21
+ }
22
+
23
+ export function QuarterWheelPicker({ value, onChange, min, max, allowClear }) {
24
+ const current = value ? dayjs(value) : dayjs()
25
+ const quarter = Math.floor(current.month() / 3) + 1
26
+ const year = current.year()
27
+
28
+ const yearOptions = useMemo(() => getYearOptions(min, max), [min, max])
29
+
30
+ const handleQuarterChange = (q) => {
31
+ onChange?.(
32
+ dayjs()
33
+ .year(year)
34
+ .month((q - 1) * 3)
35
+ .startOf('month')
36
+ )
37
+ }
38
+
39
+ const handleYearChange = (y) => {
40
+ onChange?.(
41
+ dayjs()
42
+ .year(y)
43
+ .month((quarter - 1) * 3)
44
+ .startOf('month')
45
+ )
46
+ }
47
+
48
+ return (
49
+ <View>
50
+ <View row>
51
+ <View flex>
52
+ <WheelPicker options={QUARTERS} value={quarter} onChange={handleQuarterChange} />
53
+ </View>
54
+ <View flex>
55
+ <WheelPicker options={yearOptions} value={year} onChange={handleYearChange} />
56
+ </View>
57
+ </View>
58
+ <ClearLink hide={!allowClear} value={value} onChange={onChange} />
59
+ </View>
60
+ )
61
+ }
@@ -0,0 +1,66 @@
1
+ import { useMemo } from 'react'
2
+ import dayjs from 'dayjs'
3
+ import weekOfYear from 'dayjs/esm/plugin/weekOfYear'
4
+
5
+ import { ClearLink } from '../../actions/ClearLink'
6
+ import { View } from '../../structure'
7
+ import { WheelPicker } from '../WheelPicker'
8
+
9
+ dayjs.extend(weekOfYear)
10
+
11
+ function getWeekOptions(year) {
12
+ const options = []
13
+ let d = dayjs().year(year).startOf('year').startOf('week')
14
+
15
+ while (d.year() <= year) {
16
+ const weekStart = d
17
+ const weekEnd = d.endOf('week')
18
+ const label = `W${d.week()} ${weekStart.format('MMM D')} - ${weekEnd.format('MMM D')}`
19
+ options.push({ value: d.week(), label, date: weekStart })
20
+ d = d.add(1, 'week')
21
+ }
22
+
23
+ return options
24
+ }
25
+
26
+ function getYearOptions(min, max) {
27
+ const minYear = min ? dayjs(min).year() : dayjs().year() - 100
28
+ const maxYear = max ? dayjs(max).year() : dayjs().year() + 100
29
+ const options = []
30
+ for (let y = minYear; y <= maxYear; y++) options.push({ value: y, label: String(y) })
31
+ return options
32
+ }
33
+
34
+ export function WeekWheelPicker({ value, onChange, min, max, allowClear }) {
35
+ const current = value ? dayjs(value) : dayjs()
36
+ const week = current.week()
37
+ const year = current.year()
38
+
39
+ const weekOptions = useMemo(() => getWeekOptions(year), [year])
40
+ const yearOptions = useMemo(() => getYearOptions(min, max), [min, max])
41
+
42
+ const handleWeekChange = (w) => {
43
+ const opt = weekOptions.find((o) => o.value === w)
44
+ if (opt) onChange?.(opt.date.startOf('week'))
45
+ }
46
+
47
+ const handleYearChange = (y) => {
48
+ const newWeeks = getWeekOptions(y)
49
+ const closest = newWeeks.find((o) => o.value === week) || newWeeks[0]
50
+ onChange?.(closest.date.startOf('week'))
51
+ }
52
+
53
+ return (
54
+ <View>
55
+ <View row>
56
+ <View flex>
57
+ <WheelPicker options={weekOptions} value={week} onChange={handleWeekChange} />
58
+ </View>
59
+ <View width={120}>
60
+ <WheelPicker options={yearOptions} value={year} onChange={handleYearChange} />
61
+ </View>
62
+ </View>
63
+ <ClearLink hide={!allowClear} value={value} onChange={onChange} />
64
+ </View>
65
+ )
66
+ }
@@ -0,0 +1,35 @@
1
+ import { View } from '../../structure'
2
+ import { DayWheelPicker } from './DayWheelPicker'
3
+
4
+ let DatePickerDate, DatePickerMonth
5
+ try {
6
+ const dp = require('@quidone/react-native-wheel-picker').DatePicker
7
+ DatePickerDate = dp.Date
8
+ DatePickerMonth = dp.Month
9
+ } catch {
10
+ DatePickerDate = null
11
+ DatePickerMonth = null
12
+ }
13
+
14
+ const renderHiddenDate = () => (
15
+ <View width={0} hiddenOverflow pointerEvents="none">
16
+ {DatePickerDate ? <DatePickerDate /> : null}
17
+ </View>
18
+ )
19
+
20
+ const renderHiddenMonth = () => (
21
+ <View width={0} hiddenOverflow pointerEvents="none">
22
+ {DatePickerMonth ? <DatePickerMonth /> : null}
23
+ </View>
24
+ )
25
+
26
+ export function YearWheelPicker({ value, ...props }) {
27
+ return (
28
+ <DayWheelPicker
29
+ {...props}
30
+ value={value?.startOf?.('year') ?? value}
31
+ renderDate={renderHiddenDate}
32
+ renderMonth={renderHiddenMonth}
33
+ />
34
+ )
35
+ }
@@ -21,3 +21,7 @@ export * from './SegmentedPicker'
21
21
  export * from './Editable'
22
22
  export * from './upload/Upload'
23
23
  export * from './UploadInput'
24
+ export * from './WheelPicker'
25
+ export * from './dateWheelPicker/DateWheelPicker'
26
+ export * from './NumberWheelInput'
27
+ export * from './NumberWheelPicker'
@@ -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'