@forthtilliath/react-native-kit 0.7.2 → 0.9.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 (120) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +338 -328
  3. package/dist/components/index.d.ts +5 -0
  4. package/dist/components/index.d.ts.map +1 -0
  5. package/dist/components/index.js +4 -0
  6. package/dist/components/list/SwipeableRow.d.ts.map +1 -0
  7. package/dist/components/list/Thumbnail.d.ts.map +1 -0
  8. package/dist/components/list/index.d.ts +3 -0
  9. package/dist/components/list/index.d.ts.map +1 -0
  10. package/dist/components/list/index.js +2 -0
  11. package/dist/components/picker/PhotoPicker.d.ts.map +1 -0
  12. package/dist/components/picker/PickerModal.d.ts.map +1 -0
  13. package/dist/components/picker/VoiceSearchButton.d.ts.map +1 -0
  14. package/dist/components/picker/index.d.ts +4 -0
  15. package/dist/components/picker/index.d.ts.map +1 -0
  16. package/dist/components/picker/index.js +3 -0
  17. package/dist/components/theme/ThemeOptionList.d.ts +28 -0
  18. package/dist/components/theme/ThemeOptionList.d.ts.map +1 -0
  19. package/dist/components/theme/ThemeOptionList.js +67 -0
  20. package/dist/{ThemeToggle.d.ts → components/theme/ThemeToggle.d.ts} +1 -1
  21. package/dist/components/theme/ThemeToggle.d.ts.map +1 -0
  22. package/dist/components/theme/index.d.ts +3 -0
  23. package/dist/components/theme/index.d.ts.map +1 -0
  24. package/dist/components/theme/index.js +2 -0
  25. package/dist/components/update/ChangelogNotes.d.ts.map +1 -0
  26. package/dist/components/update/UpdateAvailableBanner.d.ts.map +1 -0
  27. package/dist/components/update/index.d.ts +3 -0
  28. package/dist/components/update/index.d.ts.map +1 -0
  29. package/dist/components/update/index.js +2 -0
  30. package/dist/hooks/index.d.ts +5 -0
  31. package/dist/hooks/index.d.ts.map +1 -0
  32. package/dist/hooks/index.js +4 -0
  33. package/dist/hooks/useDebouncedChange.d.ts.map +1 -0
  34. package/dist/hooks/useEffectiveColorScheme.d.ts.map +1 -0
  35. package/dist/hooks/useSubmitGuard.d.ts.map +1 -0
  36. package/dist/hooks/useUpdateCheck.d.ts.map +1 -0
  37. package/dist/index.d.ts +22 -21
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +26 -24
  40. package/dist/utils/format/escapeCsvField.d.ts.map +1 -0
  41. package/dist/utils/format/escapeHtml.d.ts.map +1 -0
  42. package/dist/utils/format/formatCsvNumber.d.ts.map +1 -0
  43. package/dist/utils/format/index.d.ts +4 -0
  44. package/dist/utils/format/index.d.ts.map +1 -0
  45. package/dist/utils/format/index.js +3 -0
  46. package/dist/utils/helpers/confirmDestructive.d.ts.map +1 -0
  47. package/dist/utils/helpers/getMostRecentIds.d.ts.map +1 -0
  48. package/dist/utils/helpers/getPeriodStartMs.d.ts.map +1 -0
  49. package/dist/utils/helpers/index.d.ts +7 -0
  50. package/dist/utils/helpers/index.d.ts.map +1 -0
  51. package/dist/utils/helpers/index.js +6 -0
  52. package/dist/utils/helpers/nextInCycle.d.ts.map +1 -0
  53. package/dist/utils/helpers/normalizeForSearch.d.ts.map +1 -0
  54. package/dist/utils/helpers/rankByNameMatch.d.ts.map +1 -0
  55. package/dist/utils/index.d.ts +3 -0
  56. package/dist/utils/index.d.ts.map +1 -0
  57. package/dist/utils/index.js +2 -0
  58. package/package.json +2 -2
  59. package/dist/ChangelogNotes.d.ts.map +0 -1
  60. package/dist/PhotoPicker.d.ts.map +0 -1
  61. package/dist/PickerModal.d.ts.map +0 -1
  62. package/dist/SwipeableRow.d.ts.map +0 -1
  63. package/dist/ThemeToggle.d.ts.map +0 -1
  64. package/dist/Thumbnail.d.ts.map +0 -1
  65. package/dist/UpdateAvailableBanner.d.ts.map +0 -1
  66. package/dist/VoiceSearchButton.d.ts.map +0 -1
  67. package/dist/confirmDestructive.d.ts.map +0 -1
  68. package/dist/useDebouncedChange.d.ts.map +0 -1
  69. package/dist/useEffectiveColorScheme.d.ts.map +0 -1
  70. package/dist/useSubmitGuard.d.ts.map +0 -1
  71. package/dist/useUpdateCheck.d.ts.map +0 -1
  72. package/dist/utils/escapeCsvField.d.ts.map +0 -1
  73. package/dist/utils/escapeHtml.d.ts.map +0 -1
  74. package/dist/utils/formatCsvNumber.d.ts.map +0 -1
  75. package/dist/utils/getMostRecentIds.d.ts.map +0 -1
  76. package/dist/utils/getPeriodStartMs.d.ts.map +0 -1
  77. package/dist/utils/nextInCycle.d.ts.map +0 -1
  78. package/dist/utils/normalizeForSearch.d.ts.map +0 -1
  79. package/dist/utils/rankByNameMatch.d.ts.map +0 -1
  80. /package/dist/{SwipeableRow.d.ts → components/list/SwipeableRow.d.ts} +0 -0
  81. /package/dist/{SwipeableRow.js → components/list/SwipeableRow.js} +0 -0
  82. /package/dist/{Thumbnail.d.ts → components/list/Thumbnail.d.ts} +0 -0
  83. /package/dist/{Thumbnail.js → components/list/Thumbnail.js} +0 -0
  84. /package/dist/{PhotoPicker.d.ts → components/picker/PhotoPicker.d.ts} +0 -0
  85. /package/dist/{PhotoPicker.js → components/picker/PhotoPicker.js} +0 -0
  86. /package/dist/{PickerModal.d.ts → components/picker/PickerModal.d.ts} +0 -0
  87. /package/dist/{PickerModal.js → components/picker/PickerModal.js} +0 -0
  88. /package/dist/{VoiceSearchButton.d.ts → components/picker/VoiceSearchButton.d.ts} +0 -0
  89. /package/dist/{VoiceSearchButton.js → components/picker/VoiceSearchButton.js} +0 -0
  90. /package/dist/{ThemeToggle.js → components/theme/ThemeToggle.js} +0 -0
  91. /package/dist/{ChangelogNotes.d.ts → components/update/ChangelogNotes.d.ts} +0 -0
  92. /package/dist/{ChangelogNotes.js → components/update/ChangelogNotes.js} +0 -0
  93. /package/dist/{UpdateAvailableBanner.d.ts → components/update/UpdateAvailableBanner.d.ts} +0 -0
  94. /package/dist/{UpdateAvailableBanner.js → components/update/UpdateAvailableBanner.js} +0 -0
  95. /package/dist/{useDebouncedChange.d.ts → hooks/useDebouncedChange.d.ts} +0 -0
  96. /package/dist/{useDebouncedChange.js → hooks/useDebouncedChange.js} +0 -0
  97. /package/dist/{useEffectiveColorScheme.d.ts → hooks/useEffectiveColorScheme.d.ts} +0 -0
  98. /package/dist/{useEffectiveColorScheme.js → hooks/useEffectiveColorScheme.js} +0 -0
  99. /package/dist/{useSubmitGuard.d.ts → hooks/useSubmitGuard.d.ts} +0 -0
  100. /package/dist/{useSubmitGuard.js → hooks/useSubmitGuard.js} +0 -0
  101. /package/dist/{useUpdateCheck.d.ts → hooks/useUpdateCheck.d.ts} +0 -0
  102. /package/dist/{useUpdateCheck.js → hooks/useUpdateCheck.js} +0 -0
  103. /package/dist/utils/{escapeCsvField.d.ts → format/escapeCsvField.d.ts} +0 -0
  104. /package/dist/utils/{escapeCsvField.js → format/escapeCsvField.js} +0 -0
  105. /package/dist/utils/{escapeHtml.d.ts → format/escapeHtml.d.ts} +0 -0
  106. /package/dist/utils/{escapeHtml.js → format/escapeHtml.js} +0 -0
  107. /package/dist/utils/{formatCsvNumber.d.ts → format/formatCsvNumber.d.ts} +0 -0
  108. /package/dist/utils/{formatCsvNumber.js → format/formatCsvNumber.js} +0 -0
  109. /package/dist/{confirmDestructive.d.ts → utils/helpers/confirmDestructive.d.ts} +0 -0
  110. /package/dist/{confirmDestructive.js → utils/helpers/confirmDestructive.js} +0 -0
  111. /package/dist/utils/{getMostRecentIds.d.ts → helpers/getMostRecentIds.d.ts} +0 -0
  112. /package/dist/utils/{getMostRecentIds.js → helpers/getMostRecentIds.js} +0 -0
  113. /package/dist/utils/{getPeriodStartMs.d.ts → helpers/getPeriodStartMs.d.ts} +0 -0
  114. /package/dist/utils/{getPeriodStartMs.js → helpers/getPeriodStartMs.js} +0 -0
  115. /package/dist/utils/{nextInCycle.d.ts → helpers/nextInCycle.d.ts} +0 -0
  116. /package/dist/utils/{nextInCycle.js → helpers/nextInCycle.js} +0 -0
  117. /package/dist/utils/{normalizeForSearch.d.ts → helpers/normalizeForSearch.d.ts} +0 -0
  118. /package/dist/utils/{normalizeForSearch.js → helpers/normalizeForSearch.js} +0 -0
  119. /package/dist/utils/{rankByNameMatch.d.ts → helpers/rankByNameMatch.d.ts} +0 -0
  120. /package/dist/utils/{rankByNameMatch.js → helpers/rankByNameMatch.js} +0 -0
package/README.md CHANGED
@@ -1,328 +1,338 @@
1
- # @forthtilliath/react-native-kit
2
-
3
- Small React Native building blocks — components, hooks, and framework-agnostic utils — with no opinion on your app's theme, distributed one file per export so consumers only pull in what they use.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install @forthtilliath/react-native-kit react react-native
9
- ```
10
-
11
- Or, from within this monorepo, as a workspace dependency:
12
-
13
- ```json
14
- {
15
- "dependencies": {
16
- "@forthtilliath/react-native-kit": "workspace:*"
17
- }
18
- }
19
- ```
20
-
21
- `react` and `react-native` are peer dependencies — install them in the consuming app if not already present. `@expo/vector-icons`, `expo-image-picker`, `expo-speech-recognition`, and `react-native-gesture-handler` are also peer dependencies, needed only if you import the components that use them (`Thumbnail`/`PickerModal`/`SwipeableRow`, `PhotoPicker`, `VoiceSearchButton`/`PickerModal`, `SwipeableRow`, respectively).
22
-
23
- ## Usage
24
-
25
- Each component is its own module — import the file you need directly:
26
-
27
- ```ts
28
- import { ChangelogNotes } from "@forthtilliath/react-native-kit/ChangelogNotes";
29
- ```
30
-
31
- This is the recommended way to import: Metro (React Native's bundler) doesn't reliably tree-shake, so pulling from a single deep-import path keeps peer dependencies you don't use (`expo-image-picker`, `expo-speech-recognition`, `react-native-gesture-handler`...) out of your bundle entirely, rather than merely unused.
32
-
33
- A root barrel is also available for convenience:
34
-
35
- ```ts
36
- import {
37
- ChangelogNotes,
38
- useSubmitGuard,
39
- } from "@forthtilliath/react-native-kit";
40
- ```
41
-
42
- **Avoid the barrel under Jest (or any other CommonJS `require` consumer).** `export * from` re-exports are evaluated eagerly on `require()` — unlike Metro's ESM bundling, there's no tree-shaking to skip the unused ones. Requiring the barrel from _any_ file, even one that only wants a framework-agnostic util like `getMostRecentIds`, pulls in every component's module graph, including native-module imports (`expo-speech-recognition` via `VoiceSearchButton`/`PickerModal`) that don't exist in a Jest environment — this throws `Cannot find native module '...'` at require time, not just at runtime for an unrendered component. Deep imports only ever load the one module you asked for, so they don't have this problem in any environment.
43
-
44
- ### `<ChangelogNotes notes={...} styles={...} />`
45
-
46
- Renders GitHub-style release notes — as produced by `@forthtilliath/expo-release-updates`'s `parseChangelogNotes` — as headings, bulleted items, and bold-aware text, instead of showing the raw Markdown syntax in a plain `Text`.
47
-
48
- ```tsx
49
- <ChangelogNotes notes={release.notes} />
50
- ```
51
-
52
- It understands the small subset of Markdown GitHub release notes actually use:
53
-
54
- | `notes` input | Rendered as |
55
- | ----------------------------------------- | --------------------------------------------------------------------------- |
56
- | `### Added` | a heading `Text` — `"Added"` |
57
- | `- Export history to CSV.` | a row (`View`) with a `•` bullet `Text` and an item `Text` |
58
- | `**Auto-backup**: saves every 5 minutes.` | a plain `Text`, with the bold segment split into its own bold-styled `Text` |
59
-
60
- A full example, mixing all three:
61
-
62
- ```tsx
63
- <ChangelogNotes
64
- notes={`### Added\n- **Auto-backup**: saves every 5 minutes.\n- Export history to CSV.\n\nSee the full changelog for details.`}
65
- />
66
- ```
67
-
68
- The component ships with neutral default styles and no opinion on your app's theme. Override any subset via the `styles` prop to match your colors/dark mode:
69
-
70
- ```tsx
71
- <ChangelogNotes
72
- notes={release.notes}
73
- styles={{
74
- heading: { color: colors.text, fontWeight: "700" },
75
- itemRow: { gap: 8 },
76
- bullet: { color: colors.primary },
77
- itemText: { color: colors.textMuted },
78
- text: { color: colors.textMuted },
79
- bold: { color: colors.text, fontWeight: "700" },
80
- }}
81
- />
82
- ```
83
-
84
- Every field of `styles` is optional — pass only the ones you want to override; the rest fall back to the defaults (`ChangelogNotesStyles` in `ChangelogNotes.tsx`).
85
-
86
- ### `<Thumbnail photoUri={...} placeholderIcon="..." />`
87
-
88
- List-row thumbnail: the photo if there is one, otherwise a placeholder icon.
89
-
90
- ```tsx
91
- import { Thumbnail } from "@forthtilliath/react-native-kit/Thumbnail";
92
-
93
- <Thumbnail
94
- photoUri={container.photoUri}
95
- placeholderIcon="cube-outline"
96
- size={48}
97
- />;
98
- ```
99
-
100
- ### `<SwipeableRow onDelete={...} deleteLabel="...">`
101
-
102
- Swipe a list row left to reveal a delete button, on top of a tap to edit it.
103
-
104
- ```tsx
105
- import { SwipeableRow } from "@forthtilliath/react-native-kit/SwipeableRow";
106
-
107
- <SwipeableRow
108
- onDelete={() => remove(item.id)}
109
- deleteLabel={`Delete ${item.name}`}
110
- >
111
- <ItemRow item={item} />
112
- </SwipeableRow>;
113
- ```
114
-
115
- ### `<VoiceSearchButton onResult={...} />`
116
-
117
- Microphone button to dictate a search instead of typing it. Safe to mount more than one at a time (e.g. a name field plus a search picker on the same screen) — only the instance that started listening reacts to its result.
118
-
119
- ```tsx
120
- import { VoiceSearchButton } from "@forthtilliath/react-native-kit/VoiceSearchButton";
121
-
122
- <VoiceSearchButton onResult={setQuery} lang="en-US" />;
123
- ```
124
-
125
- ### `<PhotoPicker photoUri={...} onChange={...} savePhoto={...} photoLabel="..." />`
126
-
127
- Photo picker (camera or library) with a preview and a remove link. `savePhoto` is injected rather than hard-coded, so where/how the picked image gets persisted is entirely up to the caller.
128
-
129
- ```tsx
130
- import { PhotoPicker } from "@forthtilliath/react-native-kit/PhotoPicker";
131
-
132
- <PhotoPicker
133
- photoUri={container.photoUri}
134
- onChange={(uri) => setPhotoUri(uri)}
135
- savePhoto={(sourceUri) => saveContainerPhoto(sourceUri)}
136
- photoLabel="of the container"
137
- />;
138
- ```
139
-
140
- ### `<PickerModal visible title items onSelect onClose />`
141
-
142
- Full-screen picker: search (typed or dictated via `VoiceSearchButton`), optional sections, and "add" actions always visible above the results.
143
-
144
- ```tsx
145
- import {
146
- PickerModal,
147
- type PickerItem,
148
- } from "@forthtilliath/react-native-kit/PickerModal";
149
-
150
- <PickerModal
151
- visible={pickerVisible}
152
- title="Choose a container"
153
- items={containers.map((c): PickerItem => ({
154
- id: c.id,
155
- label: c.name,
156
- imageUri: c.photoUri,
157
- }))}
158
- onSelect={(item) => setContainerId(item.id)}
159
- onClose={() => setPickerVisible(false)}
160
- extraActions={[
161
- {
162
- label: "Add a new container",
163
- onPress: () => router.push("/containers/new"),
164
- },
165
- ]}
166
- />;
167
- ```
168
-
169
- Groups results into sections via each item's `group` (e.g. food groups, ingredients vs recipes) — ignored while searching, where the best global matches are shown instead. Pass `filterItems` for custom ranking (e.g. `rankByNameMatch` from this same package's `utils/`) instead of the default case-insensitive substring match.
170
-
171
- For all 5 components above, styling and (where relevant) copy work the same way as `ChangelogNotes`: an optional `styles` prop (all fields optional, neutral defaults) and, for `PhotoPicker`/`PickerModal`, an optional `labels` prop for the built-in French copy.
172
-
173
- ### `useSubmitGuard()`
174
-
175
- Prevents a second call while a first one is still pending — e.g. a double-tap on a "Save" button before it's had time to disable, which would otherwise create duplicate submissions.
176
-
177
- ```tsx
178
- import { useSubmitGuard } from "@forthtilliath/react-native-kit/useSubmitGuard";
179
-
180
- const { isSaving, guard } = useSubmitGuard();
181
-
182
- <Pressable
183
- disabled={isSaving}
184
- onPress={() =>
185
- guard(async () => {
186
- await save();
187
- })
188
- }
189
- >
190
- <Text>{isSaving ? "Saving…" : "Save"}</Text>
191
- </Pressable>;
192
- ```
193
-
194
- ### `useDebouncedChange(values, delayMs, callback)`
195
-
196
- Calls `callback` `delayMs` after the last change among `values`, ignoring renders where any value is still `undefined` (not loaded yet) and the very first render where they're all defined (no trigger on mount). A new change before the delay elapses resets the timer — a real debounce, not a throttle.
197
-
198
- ```tsx
199
- import { useDebouncedChange } from "@forthtilliath/react-native-kit/useDebouncedChange";
200
-
201
- useDebouncedChange([settingsData, itemsData], 5 * 60 * 1000, () => {
202
- runAutoBackup();
203
- });
204
- ```
205
-
206
- ### `confirmDestructive(title, onConfirm, options?)`
207
-
208
- Generic destructive-action confirmation (title + message + Cancel/Confirm), for anything irreversible (delete, reset...). Ships with French defaults (`message`, `cancelLabel`, `confirmLabel` all overridable).
209
-
210
- ```ts
211
- import { confirmDestructive } from "@forthtilliath/react-native-kit/confirmDestructive";
212
-
213
- confirmDestructive("Delete this item?", () => deleteItem(id), {
214
- message: "This cannot be undone.",
215
- cancelLabel: "Cancel",
216
- confirmLabel: "Delete",
217
- });
218
- ```
219
-
220
- ### `useEffectiveColorScheme(preference)`
221
-
222
- Resolves a `"light" | "dark" | "system"` theme preference against the device's color scheme: `"system"` follows the device, `"light"`/`"dark"` override it regardless of what the device is set to.
223
-
224
- ```tsx
225
- import { useEffectiveColorScheme } from "@forthtilliath/react-native-kit/useEffectiveColorScheme";
226
-
227
- const scheme = useEffectiveColorScheme(themePreference); // "light" | "dark"
228
- const colors = scheme === "dark" ? darkColors : lightColors;
229
- ```
230
-
231
- ### `<ThemeToggle value onChange />`
232
-
233
- 3-way segmented control for a light/dark/system theme preference.
234
-
235
- ```tsx
236
- import { ThemeToggle } from "@forthtilliath/react-native-kit/ThemeToggle";
237
-
238
- <ThemeToggle value={themePreference} onChange={setThemePreference} />;
239
- ```
240
-
241
- ### `useUpdateCheck(options)`
242
-
243
- Checks once per mount (e.g. app launch) whether a newer release is available, throttled to at most one real check per `minIntervalMs` (default 12h) and silent for a release the user already dismissed. Has no opinion on where "when did we last check" / "which version did the user dismiss" are persisted — both are read/written entirely through the options you pass in.
244
-
245
- ```tsx
246
- import { useUpdateCheck } from "@forthtilliath/react-native-kit/useUpdateCheck";
247
-
248
- const update = useUpdateCheck({
249
- currentVersion: Constants.expoConfig?.version ?? "0.0.0",
250
- checkForUpdate: fetchLatestRelease,
251
- compareVersions,
252
- getLastCheck: () => ({
253
- lastCheckedAt: settings?.lastUpdateCheckAt ?? null,
254
- dismissedVersion: settings?.dismissedUpdateVersion ?? null,
255
- }),
256
- onChecked: (lastCheckedAt) =>
257
- updateSettings({ lastUpdateCheckAt: lastCheckedAt }),
258
- });
259
-
260
- if (update.status === "available") {
261
- // update.release.version / .notes / .apkUrl
262
- }
263
- ```
264
-
265
- ### `<UpdateAvailableBanner version notes onPress onDismiss />`
266
-
267
- Dismissible banner announcing an available update: version, release notes (rendered via `ChangelogNotes`), an action button and a dismiss button. Has no opinion on what the action does (e.g. navigate to an update screen) or on how/whether dismissal is persisted.
268
-
269
- ```tsx
270
- import { UpdateAvailableBanner } from "@forthtilliath/react-native-kit/UpdateAvailableBanner";
271
-
272
- {
273
- update.status === "available" && (
274
- <UpdateAvailableBanner
275
- version={update.release.version}
276
- notes={update.release.notes}
277
- onPress={() => router.push("/settings/update")}
278
- onDismiss={() => {
279
- dismissUpdateVersion(update.release.version);
280
- update.dismiss();
281
- }}
282
- />
283
- );
284
- }
285
- ```
286
-
287
- ### Utils (`utils/`)
288
-
289
- Framework-agnostic pure functions — no React or React Native import, usable from Node/web too.
290
-
291
- ```ts
292
- import { getPeriodStartMs } from "@forthtilliath/react-native-kit/utils/getPeriodStartMs";
293
- import { getMostRecentIds } from "@forthtilliath/react-native-kit/utils/getMostRecentIds";
294
- import { nextInCycle } from "@forthtilliath/react-native-kit/utils/nextInCycle";
295
- import { normalizeForSearch } from "@forthtilliath/react-native-kit/utils/normalizeForSearch";
296
- import { rankByNameMatch } from "@forthtilliath/react-native-kit/utils/rankByNameMatch";
297
- import { escapeCsvField } from "@forthtilliath/react-native-kit/utils/escapeCsvField";
298
- import { formatCsvNumber } from "@forthtilliath/react-native-kit/utils/formatCsvNumber";
299
- import { escapeHtml } from "@forthtilliath/react-native-kit/utils/escapeHtml";
300
- ```
301
-
302
- | Function | What it does |
303
- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
304
- | `getPeriodStartMs(period, now?)` | Start timestamp (ms) for a `"today" \| "7d" \| "30d" \| "all"` period filter, or `null` for `"all"`. `"today"` is the current calendar day, not a rolling 24h. |
305
- | `getMostRecentIds(rows, limit?)` | Most recently occurring distinct ids (`{ id, occurredAt }[]`), most recent first, deduplicated, limited (default 5). |
306
- | `nextInCycle(ids, currentId)` | Next id in a short list (e.g. cycling through recents on tap) — wraps to the first if `currentId` is at the end or no longer in the list. |
307
- | `normalizeForSearch(text)` | Lowercases, trims, strips accents, and expands `œ`/`æ` ligatures (which `normalize("NFD")` alone doesn't decompose) — for accent/case-insensitive search matching. |
308
- | `rankByNameMatch(items, query, getName)` | Ranks `items` by relevance to `query`: earlier match position first, then shorter name — for a search-as-you-type list. |
309
- | `escapeCsvField(value)` | Quotes and escapes a CSV field (RFC 4180) only if it contains a `"`, `;`, or newline. |
310
- | `formatCsvNumber(value, decimals?)` | Formats a number with a comma decimal separator (French-locale spreadsheets) instead of JS's dot. |
311
- | `escapeHtml(text)` | Basic HTML entity escaping (`&`, `<`, `>`, `"`) for inserting user text into an HTML template. |
312
-
313
- ## Scripts
314
-
315
- ```bash
316
- pnpm run dev # tsc --watch -> dist/
317
- pnpm run build # tsc -> dist/
318
- pnpm run check-types # tsc --noEmit
319
- pnpm run lint # eslint
320
- pnpm run test # vitest run
321
- pnpm run test:watch # vitest
322
- ```
323
-
324
- Built to `dist/` (see the `exports` field in `package.json`), so run `pnpm run build` (or `dev`) after source changes for consumers to see them.
325
-
326
- ### Testing note
327
-
328
- `react-native`'s package entry uses Flow syntax that `@babel/parser`'s flow plugin can't parse (`as Cast` casts aren't supported there), so Vitest can never load the real package directly. Tests alias `react-native` to a minimal stub (`src/__mocks__/react-native.tsx`) instead — see that file for details. `react-native-gesture-handler` ships the same kind of Flow syntax, and `@expo/vector-icons` has its own unrelated ESM-resolution issue under Vite/Rollup — both are stubbed the same way (`src/__mocks__/react-native-gesture-handler.tsx`, `src/__mocks__/expo-vector-icons.tsx`). `expo-speech-recognition` and `expo-image-picker` wrap native modules unavailable under Vitest regardless, so they're mocked with plain spies (`src/__mocks__/expo-speech-recognition.tsx`, `src/__mocks__/expo-image-picker.ts`).
1
+ # @forthtilliath/react-native-kit
2
+
3
+ Small React Native building blocks — components, hooks, and framework-agnostic utils — with no opinion on your app's theme, distributed one file per export (grouped under `components/{picker,theme,update,list}/`, `hooks/`, `utils/{format,helpers}/`) so consumers only pull in what they use.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @forthtilliath/react-native-kit react react-native
9
+ ```
10
+
11
+ Or, from within this monorepo, as a workspace dependency:
12
+
13
+ ```json
14
+ {
15
+ "dependencies": {
16
+ "@forthtilliath/react-native-kit": "workspace:*"
17
+ }
18
+ }
19
+ ```
20
+
21
+ `react` and `react-native` are peer dependencies — install them in the consuming app if not already present. `@expo/vector-icons`, `expo-image-picker`, `expo-speech-recognition`, and `react-native-gesture-handler` are also peer dependencies, needed only if you import the components that use them (`Thumbnail`/`PickerModal`/`SwipeableRow`, `PhotoPicker`, `VoiceSearchButton`/`PickerModal`, `SwipeableRow`, respectively).
22
+
23
+ ## Usage
24
+
25
+ Each component is its own module — import the file you need directly:
26
+
27
+ ```ts
28
+ import { ChangelogNotes } from "@forthtilliath/react-native-kit/components/update/ChangelogNotes";
29
+ ```
30
+
31
+ This is the recommended way to import: Metro (React Native's bundler) doesn't reliably tree-shake, so pulling from a single deep-import path keeps peer dependencies you don't use (`expo-image-picker`, `expo-speech-recognition`, `react-native-gesture-handler`...) out of your bundle entirely, rather than merely unused.
32
+
33
+ A root barrel is also available for convenience:
34
+
35
+ ```ts
36
+ import {
37
+ ChangelogNotes,
38
+ useSubmitGuard,
39
+ } from "@forthtilliath/react-native-kit";
40
+ ```
41
+
42
+ **Avoid the barrel under Jest (or any other CommonJS `require` consumer).** `export * from` re-exports are evaluated eagerly on `require()` — unlike Metro's ESM bundling, there's no tree-shaking to skip the unused ones. Requiring the barrel from _any_ file, even one that only wants a framework-agnostic util like `getMostRecentIds`, pulls in every component's module graph, including native-module imports (`expo-speech-recognition` via `VoiceSearchButton`/`PickerModal`) that don't exist in a Jest environment — this throws `Cannot find native module '...'` at require time, not just at runtime for an unrendered component. Deep imports only ever load the one module you asked for, so they don't have this problem in any environment.
43
+
44
+ ### `<ChangelogNotes notes={...} styles={...} />`
45
+
46
+ Renders GitHub-style release notes — as produced by `@forthtilliath/expo-release-updates`'s `parseChangelogNotes` — as headings, bulleted items, and bold-aware text, instead of showing the raw Markdown syntax in a plain `Text`.
47
+
48
+ ```tsx
49
+ <ChangelogNotes notes={release.notes} />
50
+ ```
51
+
52
+ It understands the small subset of Markdown GitHub release notes actually use:
53
+
54
+ | `notes` input | Rendered as |
55
+ | ----------------------------------------- | --------------------------------------------------------------------------- |
56
+ | `### Added` | a heading `Text` — `"Added"` |
57
+ | `- Export history to CSV.` | a row (`View`) with a `•` bullet `Text` and an item `Text` |
58
+ | `**Auto-backup**: saves every 5 minutes.` | a plain `Text`, with the bold segment split into its own bold-styled `Text` |
59
+
60
+ A full example, mixing all three:
61
+
62
+ ```tsx
63
+ <ChangelogNotes
64
+ notes={`### Added\n- **Auto-backup**: saves every 5 minutes.\n- Export history to CSV.\n\nSee the full changelog for details.`}
65
+ />
66
+ ```
67
+
68
+ The component ships with neutral default styles and no opinion on your app's theme. Override any subset via the `styles` prop to match your colors/dark mode:
69
+
70
+ ```tsx
71
+ <ChangelogNotes
72
+ notes={release.notes}
73
+ styles={{
74
+ heading: { color: colors.text, fontWeight: "700" },
75
+ itemRow: { gap: 8 },
76
+ bullet: { color: colors.primary },
77
+ itemText: { color: colors.textMuted },
78
+ text: { color: colors.textMuted },
79
+ bold: { color: colors.text, fontWeight: "700" },
80
+ }}
81
+ />
82
+ ```
83
+
84
+ Every field of `styles` is optional — pass only the ones you want to override; the rest fall back to the defaults (`ChangelogNotesStyles` in `ChangelogNotes.tsx`).
85
+
86
+ ### `<Thumbnail photoUri={...} placeholderIcon="..." />`
87
+
88
+ List-row thumbnail: the photo if there is one, otherwise a placeholder icon.
89
+
90
+ ```tsx
91
+ import { Thumbnail } from "@forthtilliath/react-native-kit/components/list/Thumbnail";
92
+
93
+ <Thumbnail
94
+ photoUri={container.photoUri}
95
+ placeholderIcon="cube-outline"
96
+ size={48}
97
+ />;
98
+ ```
99
+
100
+ ### `<SwipeableRow onDelete={...} deleteLabel="...">`
101
+
102
+ Swipe a list row left to reveal a delete button, on top of a tap to edit it.
103
+
104
+ ```tsx
105
+ import { SwipeableRow } from "@forthtilliath/react-native-kit/components/list/SwipeableRow";
106
+
107
+ <SwipeableRow
108
+ onDelete={() => remove(item.id)}
109
+ deleteLabel={`Delete ${item.name}`}
110
+ >
111
+ <ItemRow item={item} />
112
+ </SwipeableRow>;
113
+ ```
114
+
115
+ ### `<VoiceSearchButton onResult={...} />`
116
+
117
+ Microphone button to dictate a search instead of typing it. Safe to mount more than one at a time (e.g. a name field plus a search picker on the same screen) — only the instance that started listening reacts to its result.
118
+
119
+ ```tsx
120
+ import { VoiceSearchButton } from "@forthtilliath/react-native-kit/components/picker/VoiceSearchButton";
121
+
122
+ <VoiceSearchButton onResult={setQuery} lang="en-US" />;
123
+ ```
124
+
125
+ ### `<PhotoPicker photoUri={...} onChange={...} savePhoto={...} photoLabel="..." />`
126
+
127
+ Photo picker (camera or library) with a preview and a remove link. `savePhoto` is injected rather than hard-coded, so where/how the picked image gets persisted is entirely up to the caller.
128
+
129
+ ```tsx
130
+ import { PhotoPicker } from "@forthtilliath/react-native-kit/components/picker/PhotoPicker";
131
+
132
+ <PhotoPicker
133
+ photoUri={container.photoUri}
134
+ onChange={(uri) => setPhotoUri(uri)}
135
+ savePhoto={(sourceUri) => saveContainerPhoto(sourceUri)}
136
+ photoLabel="of the container"
137
+ />;
138
+ ```
139
+
140
+ ### `<PickerModal visible title items onSelect onClose />`
141
+
142
+ Full-screen picker: search (typed or dictated via `VoiceSearchButton`), optional sections, and "add" actions always visible above the results.
143
+
144
+ ```tsx
145
+ import {
146
+ PickerModal,
147
+ type PickerItem,
148
+ } from "@forthtilliath/react-native-kit/components/picker/PickerModal";
149
+
150
+ <PickerModal
151
+ visible={pickerVisible}
152
+ title="Choose a container"
153
+ items={containers.map((c): PickerItem => ({
154
+ id: c.id,
155
+ label: c.name,
156
+ imageUri: c.photoUri,
157
+ }))}
158
+ onSelect={(item) => setContainerId(item.id)}
159
+ onClose={() => setPickerVisible(false)}
160
+ extraActions={[
161
+ {
162
+ label: "Add a new container",
163
+ onPress: () => router.push("/containers/new"),
164
+ },
165
+ ]}
166
+ />;
167
+ ```
168
+
169
+ Groups results into sections via each item's `group` (e.g. food groups, ingredients vs recipes) — ignored while searching, where the best global matches are shown instead. Pass `filterItems` for custom ranking (e.g. `rankByNameMatch` from this same package's `utils/`) instead of the default case-insensitive substring match.
170
+
171
+ For all 5 components above, styling and (where relevant) copy work the same way as `ChangelogNotes`: an optional `styles` prop (all fields optional, neutral defaults) and, for `PhotoPicker`/`PickerModal`, an optional `labels` prop for the built-in French copy.
172
+
173
+ ### `useSubmitGuard()`
174
+
175
+ Prevents a second call while a first one is still pending — e.g. a double-tap on a "Save" button before it's had time to disable, which would otherwise create duplicate submissions.
176
+
177
+ ```tsx
178
+ import { useSubmitGuard } from "@forthtilliath/react-native-kit/hooks/useSubmitGuard";
179
+
180
+ const { isSaving, guard } = useSubmitGuard();
181
+
182
+ <Pressable
183
+ disabled={isSaving}
184
+ onPress={() =>
185
+ guard(async () => {
186
+ await save();
187
+ })
188
+ }
189
+ >
190
+ <Text>{isSaving ? "Saving…" : "Save"}</Text>
191
+ </Pressable>;
192
+ ```
193
+
194
+ ### `useDebouncedChange(values, delayMs, callback)`
195
+
196
+ Calls `callback` `delayMs` after the last change among `values`, ignoring renders where any value is still `undefined` (not loaded yet) and the very first render where they're all defined (no trigger on mount). A new change before the delay elapses resets the timer — a real debounce, not a throttle.
197
+
198
+ ```tsx
199
+ import { useDebouncedChange } from "@forthtilliath/react-native-kit/hooks/useDebouncedChange";
200
+
201
+ useDebouncedChange([settingsData, itemsData], 5 * 60 * 1000, () => {
202
+ runAutoBackup();
203
+ });
204
+ ```
205
+
206
+ ### `confirmDestructive(title, onConfirm, options?)`
207
+
208
+ Generic destructive-action confirmation (title + message + Cancel/Confirm), for anything irreversible (delete, reset...). Ships with French defaults (`message`, `cancelLabel`, `confirmLabel` all overridable).
209
+
210
+ ```ts
211
+ import { confirmDestructive } from "@forthtilliath/react-native-kit/utils/helpers/confirmDestructive";
212
+
213
+ confirmDestructive("Delete this item?", () => deleteItem(id), {
214
+ message: "This cannot be undone.",
215
+ cancelLabel: "Cancel",
216
+ confirmLabel: "Delete",
217
+ });
218
+ ```
219
+
220
+ ### `useEffectiveColorScheme(preference)`
221
+
222
+ Resolves a `"light" | "dark" | "system"` theme preference against the device's color scheme: `"system"` follows the device, `"light"`/`"dark"` override it regardless of what the device is set to.
223
+
224
+ ```tsx
225
+ import { useEffectiveColorScheme } from "@forthtilliath/react-native-kit/hooks/useEffectiveColorScheme";
226
+
227
+ const scheme = useEffectiveColorScheme(themePreference); // "light" | "dark"
228
+ const colors = scheme === "dark" ? darkColors : lightColors;
229
+ ```
230
+
231
+ ### `<ThemeToggle value onChange />`
232
+
233
+ 3-way segmented control for a light/dark/system theme preference.
234
+
235
+ ```tsx
236
+ import { ThemeToggle } from "@forthtilliath/react-native-kit/components/theme/ThemeToggle";
237
+
238
+ <ThemeToggle value={themePreference} onChange={setThemePreference} />;
239
+ ```
240
+
241
+ ### `<ThemeOptionList value onChange />`
242
+
243
+ Same light/dark/system data contract as `ThemeToggle`, but rendered as a list of full-width rows (icon + label + checkmark on the active one) instead of a segmented control.
244
+
245
+ ```tsx
246
+ import { ThemeOptionList } from "@forthtilliath/react-native-kit/components/theme/ThemeOptionList";
247
+
248
+ <ThemeOptionList value={themePreference} onChange={setThemePreference} />;
249
+ ```
250
+
251
+ ### `useUpdateCheck(options)`
252
+
253
+ Checks once per mount (e.g. app launch) whether a newer release is available, throttled to at most one real check per `minIntervalMs` (default 12h) and silent for a release the user already dismissed. Has no opinion on where "when did we last check" / "which version did the user dismiss" are persisted — both are read/written entirely through the options you pass in.
254
+
255
+ ```tsx
256
+ import { useUpdateCheck } from "@forthtilliath/react-native-kit/hooks/useUpdateCheck";
257
+
258
+ const update = useUpdateCheck({
259
+ currentVersion: Constants.expoConfig?.version ?? "0.0.0",
260
+ checkForUpdate: fetchLatestRelease,
261
+ compareVersions,
262
+ getLastCheck: () => ({
263
+ lastCheckedAt: settings?.lastUpdateCheckAt ?? null,
264
+ dismissedVersion: settings?.dismissedUpdateVersion ?? null,
265
+ }),
266
+ onChecked: (lastCheckedAt) =>
267
+ updateSettings({ lastUpdateCheckAt: lastCheckedAt }),
268
+ });
269
+
270
+ if (update.status === "available") {
271
+ // update.release.version / .notes / .apkUrl
272
+ }
273
+ ```
274
+
275
+ ### `<UpdateAvailableBanner version notes onPress onDismiss />`
276
+
277
+ Dismissible banner announcing an available update: version, release notes (rendered via `ChangelogNotes`), an action button and a dismiss button. Has no opinion on what the action does (e.g. navigate to an update screen) or on how/whether dismissal is persisted.
278
+
279
+ ```tsx
280
+ import { UpdateAvailableBanner } from "@forthtilliath/react-native-kit/components/update/UpdateAvailableBanner";
281
+
282
+ {
283
+ update.status === "available" && (
284
+ <UpdateAvailableBanner
285
+ version={update.release.version}
286
+ notes={update.release.notes}
287
+ onPress={() => router.push("/settings/update")}
288
+ onDismiss={() => {
289
+ dismissUpdateVersion(update.release.version);
290
+ update.dismiss();
291
+ }}
292
+ />
293
+ );
294
+ }
295
+ ```
296
+
297
+ ### Utils (`utils/`)
298
+
299
+ Framework-agnostic pure functions — no React or React Native import, usable from Node/web too.
300
+
301
+ ```ts
302
+ import { getPeriodStartMs } from "@forthtilliath/react-native-kit/utils/helpers/getPeriodStartMs";
303
+ import { getMostRecentIds } from "@forthtilliath/react-native-kit/utils/helpers/getMostRecentIds";
304
+ import { nextInCycle } from "@forthtilliath/react-native-kit/utils/helpers/nextInCycle";
305
+ import { normalizeForSearch } from "@forthtilliath/react-native-kit/utils/helpers/normalizeForSearch";
306
+ import { rankByNameMatch } from "@forthtilliath/react-native-kit/utils/helpers/rankByNameMatch";
307
+ import { escapeCsvField } from "@forthtilliath/react-native-kit/utils/format/escapeCsvField";
308
+ import { formatCsvNumber } from "@forthtilliath/react-native-kit/utils/format/formatCsvNumber";
309
+ import { escapeHtml } from "@forthtilliath/react-native-kit/utils/format/escapeHtml";
310
+ ```
311
+
312
+ | Function | What it does |
313
+ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
314
+ | `getPeriodStartMs(period, now?)` | Start timestamp (ms) for a `"today" \| "7d" \| "30d" \| "all"` period filter, or `null` for `"all"`. `"today"` is the current calendar day, not a rolling 24h. |
315
+ | `getMostRecentIds(rows, limit?)` | Most recently occurring distinct ids (`{ id, occurredAt }[]`), most recent first, deduplicated, limited (default 5). |
316
+ | `nextInCycle(ids, currentId)` | Next id in a short list (e.g. cycling through recents on tap) — wraps to the first if `currentId` is at the end or no longer in the list. |
317
+ | `normalizeForSearch(text)` | Lowercases, trims, strips accents, and expands `œ`/`æ` ligatures (which `normalize("NFD")` alone doesn't decompose) — for accent/case-insensitive search matching. |
318
+ | `rankByNameMatch(items, query, getName)` | Ranks `items` by relevance to `query`: earlier match position first, then shorter name — for a search-as-you-type list. |
319
+ | `escapeCsvField(value)` | Quotes and escapes a CSV field (RFC 4180) only if it contains a `"`, `;`, or newline. |
320
+ | `formatCsvNumber(value, decimals?)` | Formats a number with a comma decimal separator (French-locale spreadsheets) instead of JS's dot. |
321
+ | `escapeHtml(text)` | Basic HTML entity escaping (`&`, `<`, `>`, `"`) for inserting user text into an HTML template. |
322
+
323
+ ## Scripts
324
+
325
+ ```bash
326
+ pnpm run dev # tsc --watch -> dist/
327
+ pnpm run build # tsc -> dist/
328
+ pnpm run check-types # tsc --noEmit
329
+ pnpm run lint # eslint
330
+ pnpm run test # vitest run
331
+ pnpm run test:watch # vitest
332
+ ```
333
+
334
+ Built to `dist/` (see the `exports` field in `package.json`), so run `pnpm run build` (or `dev`) after source changes for consumers to see them.
335
+
336
+ ### Testing note
337
+
338
+ `react-native`'s package entry uses Flow syntax that `@babel/parser`'s flow plugin can't parse (`as Cast` casts aren't supported there), so Vitest can never load the real package directly. Tests alias `react-native` to a minimal stub (`src/__mocks__/react-native.tsx`) instead — see that file for details. `react-native-gesture-handler` ships the same kind of Flow syntax, and `@expo/vector-icons` has its own unrelated ESM-resolution issue under Vite/Rollup — both are stubbed the same way (`src/__mocks__/react-native-gesture-handler.tsx`, `src/__mocks__/expo-vector-icons.tsx`). `expo-speech-recognition` and `expo-image-picker` wrap native modules unavailable under Vitest regardless, so they're mocked with plain spies (`src/__mocks__/expo-speech-recognition.tsx`, `src/__mocks__/expo-image-picker.ts`).