@forthtilliath/react-native-kit 0.2.0 → 0.4.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 (50) hide show
  1. package/README.md +163 -3
  2. package/dist/PhotoPicker.d.ts +34 -0
  3. package/dist/PhotoPicker.d.ts.map +1 -0
  4. package/dist/PhotoPicker.js +106 -0
  5. package/dist/PickerModal.d.ts +67 -0
  6. package/dist/PickerModal.d.ts.map +1 -0
  7. package/dist/PickerModal.js +140 -0
  8. package/dist/SwipeableRow.d.ts +16 -0
  9. package/dist/SwipeableRow.d.ts.map +1 -0
  10. package/dist/SwipeableRow.js +27 -0
  11. package/dist/Thumbnail.d.ts +16 -0
  12. package/dist/Thumbnail.d.ts.map +1 -0
  13. package/dist/Thumbnail.js +23 -0
  14. package/dist/VoiceSearchButton.d.ts +14 -0
  15. package/dist/VoiceSearchButton.d.ts.map +1 -0
  16. package/dist/VoiceSearchButton.js +57 -0
  17. package/dist/confirmDestructive.d.ts +7 -0
  18. package/dist/confirmDestructive.d.ts.map +1 -0
  19. package/dist/confirmDestructive.js +13 -0
  20. package/dist/useDebouncedChange.d.ts +2 -0
  21. package/dist/useDebouncedChange.d.ts.map +1 -0
  22. package/dist/useDebouncedChange.js +24 -0
  23. package/dist/useSubmitGuard.d.ts +5 -0
  24. package/dist/useSubmitGuard.d.ts.map +1 -0
  25. package/dist/useSubmitGuard.js +19 -0
  26. package/dist/utils/escapeCsvField.d.ts +2 -0
  27. package/dist/utils/escapeCsvField.d.ts.map +1 -0
  28. package/dist/utils/escapeCsvField.js +8 -0
  29. package/dist/utils/escapeHtml.d.ts +2 -0
  30. package/dist/utils/escapeHtml.d.ts.map +1 -0
  31. package/dist/utils/escapeHtml.js +9 -0
  32. package/dist/utils/formatCsvNumber.d.ts +2 -0
  33. package/dist/utils/formatCsvNumber.d.ts.map +1 -0
  34. package/dist/utils/formatCsvNumber.js +6 -0
  35. package/dist/utils/getMostRecentIds.d.ts +6 -0
  36. package/dist/utils/getMostRecentIds.d.ts.map +1 -0
  37. package/dist/utils/getMostRecentIds.js +19 -0
  38. package/dist/utils/getPeriodStartMs.d.ts +3 -0
  39. package/dist/utils/getPeriodStartMs.d.ts.map +1 -0
  40. package/dist/utils/getPeriodStartMs.js +14 -0
  41. package/dist/utils/nextInCycle.d.ts +2 -0
  42. package/dist/utils/nextInCycle.d.ts.map +1 -0
  43. package/dist/utils/nextInCycle.js +9 -0
  44. package/dist/utils/normalizeForSearch.d.ts +2 -0
  45. package/dist/utils/normalizeForSearch.d.ts.map +1 -0
  46. package/dist/utils/normalizeForSearch.js +15 -0
  47. package/dist/utils/rankByNameMatch.d.ts +2 -0
  48. package/dist/utils/rankByNameMatch.d.ts.map +1 -0
  49. package/dist/utils/rankByNameMatch.js +33 -0
  50. package/package.json +10 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @forthtilliath/react-native-kit
2
2
 
3
- Small, dependency-free-of-app-theme React Native UI components, distributed one file per component so consumers only pull in what they use.
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
4
 
5
5
  ## Install
6
6
 
@@ -18,7 +18,7 @@ Or, from within this monorepo, as a workspace dependency:
18
18
  }
19
19
  ```
20
20
 
21
- `react` and `react-native` are peer dependencies — install them in the consuming app if not already present.
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
22
 
23
23
  ## Usage
24
24
 
@@ -70,6 +70,166 @@ The component ships with neutral default styles and no opinion on your app's the
70
70
 
71
71
  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`).
72
72
 
73
+ ### `<Thumbnail photoUri={...} placeholderIcon="..." />`
74
+
75
+ List-row thumbnail: the photo if there is one, otherwise a placeholder icon.
76
+
77
+ ```tsx
78
+ import { Thumbnail } from "@forthtilliath/react-native-kit/Thumbnail";
79
+
80
+ <Thumbnail
81
+ photoUri={container.photoUri}
82
+ placeholderIcon="cube-outline"
83
+ size={48}
84
+ />;
85
+ ```
86
+
87
+ ### `<SwipeableRow onDelete={...} deleteLabel="...">`
88
+
89
+ Swipe a list row left to reveal a delete button, on top of a tap to edit it.
90
+
91
+ ```tsx
92
+ import { SwipeableRow } from "@forthtilliath/react-native-kit/SwipeableRow";
93
+
94
+ <SwipeableRow
95
+ onDelete={() => remove(item.id)}
96
+ deleteLabel={`Delete ${item.name}`}
97
+ >
98
+ <ItemRow item={item} />
99
+ </SwipeableRow>;
100
+ ```
101
+
102
+ ### `<VoiceSearchButton onResult={...} />`
103
+
104
+ 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.
105
+
106
+ ```tsx
107
+ import { VoiceSearchButton } from "@forthtilliath/react-native-kit/VoiceSearchButton";
108
+
109
+ <VoiceSearchButton onResult={setQuery} lang="en-US" />;
110
+ ```
111
+
112
+ ### `<PhotoPicker photoUri={...} onChange={...} savePhoto={...} photoLabel="..." />`
113
+
114
+ 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.
115
+
116
+ ```tsx
117
+ import { PhotoPicker } from "@forthtilliath/react-native-kit/PhotoPicker";
118
+
119
+ <PhotoPicker
120
+ photoUri={container.photoUri}
121
+ onChange={(uri) => setPhotoUri(uri)}
122
+ savePhoto={(sourceUri) => saveContainerPhoto(sourceUri)}
123
+ photoLabel="of the container"
124
+ />;
125
+ ```
126
+
127
+ ### `<PickerModal visible title items onSelect onClose />`
128
+
129
+ Full-screen picker: search (typed or dictated via `VoiceSearchButton`), optional sections, and "add" actions always visible above the results.
130
+
131
+ ```tsx
132
+ import {
133
+ PickerModal,
134
+ type PickerItem,
135
+ } from "@forthtilliath/react-native-kit/PickerModal";
136
+
137
+ <PickerModal
138
+ visible={pickerVisible}
139
+ title="Choose a container"
140
+ items={containers.map((c): PickerItem => ({
141
+ id: c.id,
142
+ label: c.name,
143
+ imageUri: c.photoUri,
144
+ }))}
145
+ onSelect={(item) => setContainerId(item.id)}
146
+ onClose={() => setPickerVisible(false)}
147
+ extraActions={[
148
+ {
149
+ label: "Add a new container",
150
+ onPress: () => router.push("/containers/new"),
151
+ },
152
+ ]}
153
+ />;
154
+ ```
155
+
156
+ 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.
157
+
158
+ 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.
159
+
160
+ ### `useSubmitGuard()`
161
+
162
+ 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.
163
+
164
+ ```tsx
165
+ import { useSubmitGuard } from "@forthtilliath/react-native-kit/useSubmitGuard";
166
+
167
+ const { isSaving, guard } = useSubmitGuard();
168
+
169
+ <Pressable
170
+ disabled={isSaving}
171
+ onPress={() =>
172
+ guard(async () => {
173
+ await save();
174
+ })
175
+ }
176
+ >
177
+ <Text>{isSaving ? "Saving…" : "Save"}</Text>
178
+ </Pressable>;
179
+ ```
180
+
181
+ ### `useDebouncedChange(values, delayMs, callback)`
182
+
183
+ 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.
184
+
185
+ ```tsx
186
+ import { useDebouncedChange } from "@forthtilliath/react-native-kit/useDebouncedChange";
187
+
188
+ useDebouncedChange([settingsData, itemsData], 5 * 60 * 1000, () => {
189
+ runAutoBackup();
190
+ });
191
+ ```
192
+
193
+ ### `confirmDestructive(title, onConfirm, options?)`
194
+
195
+ Generic destructive-action confirmation (title + message + Cancel/Confirm), for anything irreversible (delete, reset...). Ships with French defaults (`message`, `cancelLabel`, `confirmLabel` all overridable).
196
+
197
+ ```ts
198
+ import { confirmDestructive } from "@forthtilliath/react-native-kit/confirmDestructive";
199
+
200
+ confirmDestructive("Delete this item?", () => deleteItem(id), {
201
+ message: "This cannot be undone.",
202
+ cancelLabel: "Cancel",
203
+ confirmLabel: "Delete",
204
+ });
205
+ ```
206
+
207
+ ### Utils (`utils/`)
208
+
209
+ Framework-agnostic pure functions — no React or React Native import, usable from Node/web too.
210
+
211
+ ```ts
212
+ import { getPeriodStartMs } from "@forthtilliath/react-native-kit/utils/getPeriodStartMs";
213
+ import { getMostRecentIds } from "@forthtilliath/react-native-kit/utils/getMostRecentIds";
214
+ import { nextInCycle } from "@forthtilliath/react-native-kit/utils/nextInCycle";
215
+ import { normalizeForSearch } from "@forthtilliath/react-native-kit/utils/normalizeForSearch";
216
+ import { rankByNameMatch } from "@forthtilliath/react-native-kit/utils/rankByNameMatch";
217
+ import { escapeCsvField } from "@forthtilliath/react-native-kit/utils/escapeCsvField";
218
+ import { formatCsvNumber } from "@forthtilliath/react-native-kit/utils/formatCsvNumber";
219
+ import { escapeHtml } from "@forthtilliath/react-native-kit/utils/escapeHtml";
220
+ ```
221
+
222
+ | Function | What it does |
223
+ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
224
+ | `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. |
225
+ | `getMostRecentIds(rows, limit?)` | Most recently occurring distinct ids (`{ id, occurredAt }[]`), most recent first, deduplicated, limited (default 5). |
226
+ | `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. |
227
+ | `normalizeForSearch(text)` | Lowercases, trims, strips accents, and expands `œ`/`æ` ligatures (which `normalize("NFD")` alone doesn't decompose) — for accent/case-insensitive search matching. |
228
+ | `rankByNameMatch(items, query, getName)` | Ranks `items` by relevance to `query`: earlier match position first, then shorter name — for a search-as-you-type list. |
229
+ | `escapeCsvField(value)` | Quotes and escapes a CSV field (RFC 4180) only if it contains a `"`, `;`, or newline. |
230
+ | `formatCsvNumber(value, decimals?)` | Formats a number with a comma decimal separator (French-locale spreadsheets) instead of JS's dot. |
231
+ | `escapeHtml(text)` | Basic HTML entity escaping (`&`, `<`, `>`, `"`) for inserting user text into an HTML template. |
232
+
73
233
  ## Scripts
74
234
 
75
235
  ```bash
@@ -85,4 +245,4 @@ Built to `dist/` (see the `exports` field in `package.json`), so run `pnpm run b
85
245
 
86
246
  ### Testing note
87
247
 
88
- `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.
248
+ `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`).
@@ -0,0 +1,34 @@
1
+ import type { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native";
2
+ export interface PhotoPickerStyles {
3
+ photoPicker?: StyleProp<ViewStyle>;
4
+ photoPreview?: StyleProp<ImageStyle>;
5
+ photoPlaceholder?: StyleProp<ViewStyle>;
6
+ photoPlaceholderText?: StyleProp<TextStyle>;
7
+ clearLink?: StyleProp<TextStyle>;
8
+ iconColor?: string;
9
+ }
10
+ export interface PhotoPickerLabels {
11
+ photoTitle?: (photoLabel: string) => string;
12
+ editAccessibilityLabel?: (photoLabel: string) => string;
13
+ addAccessibilityLabel?: (photoLabel: string) => string;
14
+ addPlaceholderText?: string;
15
+ cameraPermissionTitle?: string;
16
+ cameraPermissionMessage?: string;
17
+ libraryPermissionTitle?: string;
18
+ libraryPermissionMessage?: string;
19
+ takePhoto?: string;
20
+ chooseFromLibrary?: string;
21
+ cancel?: string;
22
+ removePhoto?: string;
23
+ }
24
+ export interface PhotoPickerProps {
25
+ photoUri: string | null;
26
+ onChange: (uri: string | null) => void;
27
+ savePhoto: (sourceUri: string) => Promise<string>;
28
+ /** Used as-is in titles/accessibility labels, e.g. "of the container". */
29
+ photoLabel: string;
30
+ labels?: PhotoPickerLabels;
31
+ styles?: PhotoPickerStyles;
32
+ }
33
+ export declare function PhotoPicker({ photoUri, onChange, savePhoto, photoLabel, labels, styles, }: PhotoPickerProps): import("react").JSX.Element;
34
+ //# sourceMappingURL=PhotoPicker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PhotoPicker.d.ts","sourceRoot":"","sources":["../src/PhotoPicker.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAKhF,MAAM,WAAW,iBAAiB;IAChC,WAAW,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACnC,YAAY,CAAC,EAAE,SAAS,CAAC,UAAU,CAAC,CAAC;IACrC,gBAAgB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACxC,oBAAoB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5C,sBAAsB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IACxD,qBAAqB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IACvD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACvC,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAClD,0EAA0E;IAC1E,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,MAAM,CAAC,EAAE,iBAAiB,CAAC;CAC5B;AAkDD,wBAAgB,WAAW,CAAC,EAC1B,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,UAAU,EACV,MAAM,EACN,MAAM,GACP,EAAE,gBAAgB,+BAoGlB"}
@@ -0,0 +1,106 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Alert, Image, Pressable, Text, View } from "react-native";
3
+ import { Ionicons } from "@expo/vector-icons";
4
+ import * as ImagePicker from "expo-image-picker";
5
+ const defaultStyles = {
6
+ photoPicker: { alignSelf: "center", marginTop: 8 },
7
+ photoPreview: {
8
+ width: 140,
9
+ height: 140,
10
+ borderRadius: 16,
11
+ backgroundColor: "#f9fafb",
12
+ },
13
+ photoPlaceholder: {
14
+ width: 140,
15
+ height: 140,
16
+ borderRadius: 16,
17
+ borderWidth: 1,
18
+ borderStyle: "dashed",
19
+ borderColor: "#e5e7eb",
20
+ backgroundColor: "#f9fafb",
21
+ alignItems: "center",
22
+ justifyContent: "center",
23
+ gap: 6,
24
+ },
25
+ photoPlaceholderText: { fontSize: 12, color: "#6b7280" },
26
+ clearLink: {
27
+ color: "#2563eb",
28
+ fontSize: 13,
29
+ textAlign: "center",
30
+ marginTop: 8,
31
+ },
32
+ iconColor: "#6b7280",
33
+ };
34
+ const defaultLabels = {
35
+ photoTitle: (photoLabel) => `Photo ${photoLabel}`,
36
+ editAccessibilityLabel: (photoLabel) => `Photo ${photoLabel}. Modifier.`,
37
+ addAccessibilityLabel: (photoLabel) => `Ajouter une photo ${photoLabel}`,
38
+ addPlaceholderText: "Ajouter une photo",
39
+ cameraPermissionTitle: "Accès à l'appareil photo refusé",
40
+ cameraPermissionMessage: "Autorise l'accès dans les réglages du téléphone pour prendre une photo.",
41
+ libraryPermissionTitle: "Accès aux photos refusé",
42
+ libraryPermissionMessage: "Autorise l'accès dans les réglages du téléphone pour choisir une photo.",
43
+ takePhoto: "Prendre une photo",
44
+ chooseFromLibrary: "Choisir dans la galerie",
45
+ cancel: "Annuler",
46
+ removePhoto: "Retirer la photo",
47
+ };
48
+ // Photo picker (camera or library) with a preview.
49
+ export function PhotoPicker({ photoUri, onChange, savePhoto, photoLabel, labels, styles, }) {
50
+ const mergedStyles = { ...defaultStyles, ...styles };
51
+ const t = { ...defaultLabels, ...labels };
52
+ async function pickFromCamera() {
53
+ const permission = await ImagePicker.requestCameraPermissionsAsync();
54
+ if (!permission.granted) {
55
+ Alert.alert(t.cameraPermissionTitle, t.cameraPermissionMessage);
56
+ return;
57
+ }
58
+ const result = await ImagePicker.launchCameraAsync({
59
+ mediaTypes: "images",
60
+ quality: 0.5,
61
+ allowsEditing: true,
62
+ aspect: [1, 1],
63
+ });
64
+ if (!result.canceled && result.assets[0]) {
65
+ onChange(await savePhoto(result.assets[0].uri));
66
+ }
67
+ }
68
+ async function pickFromLibrary() {
69
+ const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
70
+ if (!permission.granted) {
71
+ Alert.alert(t.libraryPermissionTitle, t.libraryPermissionMessage);
72
+ return;
73
+ }
74
+ const result = await ImagePicker.launchImageLibraryAsync({
75
+ mediaTypes: "images",
76
+ quality: 0.5,
77
+ allowsEditing: true,
78
+ aspect: [1, 1],
79
+ });
80
+ if (!result.canceled && result.assets[0]) {
81
+ onChange(await savePhoto(result.assets[0].uri));
82
+ }
83
+ }
84
+ function handlePickPhoto() {
85
+ Alert.alert(t.photoTitle(photoLabel), undefined, [
86
+ {
87
+ text: t.takePhoto,
88
+ onPress: () => {
89
+ void pickFromCamera();
90
+ },
91
+ },
92
+ {
93
+ text: t.chooseFromLibrary,
94
+ onPress: () => {
95
+ void pickFromLibrary();
96
+ },
97
+ },
98
+ { text: t.cancel, style: "cancel" },
99
+ ]);
100
+ }
101
+ return (_jsxs(_Fragment, { children: [_jsx(Pressable, { style: mergedStyles.photoPicker, onPress: handlePickPhoto, accessibilityRole: "button", accessibilityLabel: photoUri
102
+ ? t.editAccessibilityLabel(photoLabel)
103
+ : t.addAccessibilityLabel(photoLabel), children: photoUri ? (_jsx(Image, { source: { uri: photoUri }, style: mergedStyles.photoPreview, accessibilityIgnoresInvertColors: true })) : (_jsxs(View, { style: mergedStyles.photoPlaceholder, children: [_jsx(Ionicons, { name: "camera-outline", size: 28, color: mergedStyles.iconColor }), _jsx(Text, { style: mergedStyles.photoPlaceholderText, children: t.addPlaceholderText })] })) }), photoUri ? (_jsx(Pressable, { onPress: () => {
104
+ onChange(null);
105
+ }, accessibilityRole: "button", accessibilityLabel: t.removePhoto, children: _jsx(Text, { style: mergedStyles.clearLink, children: t.removePhoto }) })) : null] }));
106
+ }
@@ -0,0 +1,67 @@
1
+ import type { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native";
2
+ export interface PickerItem {
3
+ id: number;
4
+ label: string;
5
+ subtitle?: string;
6
+ imageUri?: string | null;
7
+ /**
8
+ * Groups results into sections (e.g. food groups, ingredients vs recipes)
9
+ * while browsing without searching. Ignored during an active search: the
10
+ * best global matches are shown instead, not the grouped-by-section list.
11
+ */
12
+ group?: string;
13
+ }
14
+ export interface PickerModalStyles {
15
+ container?: StyleProp<ViewStyle>;
16
+ header?: StyleProp<ViewStyle>;
17
+ title?: StyleProp<TextStyle>;
18
+ close?: StyleProp<TextStyle>;
19
+ searchRow?: StyleProp<ViewStyle>;
20
+ search?: StyleProp<TextStyle>;
21
+ row?: StyleProp<ViewStyle>;
22
+ rowThumbnail?: StyleProp<ImageStyle>;
23
+ rowThumbnailPlaceholder?: StyleProp<ViewStyle>;
24
+ rowLabel?: StyleProp<TextStyle>;
25
+ rowSubtitle?: StyleProp<TextStyle>;
26
+ empty?: StyleProp<TextStyle>;
27
+ sectionHeader?: StyleProp<TextStyle>;
28
+ extraActions?: StyleProp<ViewStyle>;
29
+ extraActionLabel?: StyleProp<TextStyle>;
30
+ iconColor?: string;
31
+ placeholderTextColor?: string;
32
+ }
33
+ export interface PickerModalLabels {
34
+ close?: string;
35
+ searchPlaceholder?: string;
36
+ searchAccessibilityLabel?: string;
37
+ voiceSearchAccessibilityLabel?: string;
38
+ defaultEmptyMessage?: string;
39
+ otherGroupLabel?: string;
40
+ }
41
+ export interface PickerModalProps {
42
+ visible: boolean;
43
+ title: string;
44
+ items: PickerItem[];
45
+ onSelect: (item: PickerItem) => void;
46
+ onClose: () => void;
47
+ emptyMessage?: string;
48
+ /** Initial search text on open; defaults to empty. */
49
+ initialQuery?: string;
50
+ /** Custom filter (e.g. relevance ranking); defaults to a case-insensitive substring match. */
51
+ filterItems?: (items: PickerItem[], query: string) => PickerItem[];
52
+ /** "Add" actions always visible at the top, even when the search matches nothing. */
53
+ extraActions?: {
54
+ label: string;
55
+ onPress: () => void;
56
+ }[];
57
+ /**
58
+ * Explicit section order (e.g. "Recent" before others); sections default
59
+ * to alphabetical order by title. A group absent from this list is placed
60
+ * after, alphabetically.
61
+ */
62
+ groupOrder?: string[];
63
+ labels?: PickerModalLabels;
64
+ styles?: PickerModalStyles;
65
+ }
66
+ export declare function PickerModal({ visible, title, items, onSelect, onClose, emptyMessage, initialQuery, filterItems, extraActions, groupOrder, labels, styles, }: PickerModalProps): import("react").JSX.Element;
67
+ //# sourceMappingURL=PickerModal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PickerModal.d.ts","sourceRoot":"","sources":["../src/PickerModal.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAchF,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9B,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9B,GAAG,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC3B,YAAY,CAAC,EAAE,SAAS,CAAC,UAAU,CAAC,CAAC;IACrC,uBAAuB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC/C,QAAQ,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAChC,WAAW,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACnC,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,aAAa,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACrC,YAAY,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACpC,gBAAgB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC,6BAA6B,CAAC,EAAE,MAAM,CAAC;IACvC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,QAAQ,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,CAAC;IACrC,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sDAAsD;IACtD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8FAA8F;IAC9F,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,KAAK,EAAE,MAAM,KAAK,UAAU,EAAE,CAAC;IACnE,qFAAqF;IACrF,YAAY,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,IAAI,CAAA;KAAE,EAAE,CAAC;IACxD;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,MAAM,CAAC,EAAE,iBAAiB,CAAC;CAC5B;AAuFD,wBAAgB,WAAW,CAAC,EAC1B,OAAO,EACP,KAAK,EACL,KAAK,EACL,QAAQ,EACR,OAAO,EACP,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,UAAU,EACV,MAAM,EACN,MAAM,GACP,EAAE,gBAAgB,+BA4JlB"}
@@ -0,0 +1,140 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from "react";
3
+ import { Image, Modal, Pressable, SectionList, Text, TextInput, View, } from "react-native";
4
+ import { Ionicons } from "@expo/vector-icons";
5
+ import { VoiceSearchButton } from "./VoiceSearchButton.js";
6
+ const defaultStyles = {
7
+ container: {
8
+ flex: 1,
9
+ backgroundColor: "#ffffff",
10
+ paddingTop: 60,
11
+ paddingHorizontal: 16,
12
+ },
13
+ header: {
14
+ flexDirection: "row",
15
+ justifyContent: "space-between",
16
+ alignItems: "center",
17
+ marginBottom: 12,
18
+ },
19
+ title: { fontSize: 20, fontWeight: "700", color: "#111827" },
20
+ close: { fontSize: 16, color: "#2563eb" },
21
+ searchRow: {
22
+ flexDirection: "row",
23
+ alignItems: "center",
24
+ gap: 10,
25
+ marginBottom: 8,
26
+ },
27
+ search: {
28
+ backgroundColor: "#f9fafb",
29
+ borderWidth: 1,
30
+ borderColor: "#e5e7eb",
31
+ borderRadius: 10,
32
+ paddingHorizontal: 12,
33
+ paddingVertical: 10,
34
+ fontSize: 16,
35
+ color: "#111827",
36
+ flex: 1,
37
+ },
38
+ row: {
39
+ flexDirection: "row",
40
+ alignItems: "center",
41
+ gap: 12,
42
+ backgroundColor: "#f9fafb",
43
+ borderWidth: 1,
44
+ borderColor: "#e5e7eb",
45
+ borderRadius: 10,
46
+ padding: 14,
47
+ marginBottom: 8,
48
+ },
49
+ rowThumbnail: {
50
+ width: 40,
51
+ height: 40,
52
+ borderRadius: 8,
53
+ backgroundColor: "#ffffff",
54
+ },
55
+ rowThumbnailPlaceholder: {
56
+ width: 40,
57
+ height: 40,
58
+ borderRadius: 8,
59
+ backgroundColor: "#ffffff",
60
+ alignItems: "center",
61
+ justifyContent: "center",
62
+ },
63
+ rowLabel: { fontSize: 16, fontWeight: "600", color: "#111827" },
64
+ rowSubtitle: { fontSize: 13, color: "#6b7280", marginTop: 2 },
65
+ empty: { textAlign: "center", color: "#6b7280", marginTop: 24 },
66
+ sectionHeader: {
67
+ fontSize: 13,
68
+ fontWeight: "700",
69
+ color: "#6b7280",
70
+ textTransform: "uppercase",
71
+ marginTop: 8,
72
+ marginBottom: 6,
73
+ },
74
+ extraActions: { marginBottom: 4 },
75
+ extraActionLabel: { color: "#2563eb" },
76
+ iconColor: "#6b7280",
77
+ placeholderTextColor: "#6b7280",
78
+ };
79
+ const defaultLabels = {
80
+ close: "Fermer",
81
+ searchPlaceholder: "Rechercher…",
82
+ searchAccessibilityLabel: "Rechercher",
83
+ voiceSearchAccessibilityLabel: "Dicter la recherche",
84
+ defaultEmptyMessage: "Aucun résultat.",
85
+ otherGroupLabel: "Autres",
86
+ };
87
+ // Full-screen picker: search (typed or dictated), optional sections, and
88
+ // "add" actions always visible above the results.
89
+ export function PickerModal({ visible, title, items, onSelect, onClose, emptyMessage, initialQuery, filterItems, extraActions, groupOrder, labels, styles, }) {
90
+ const merged = useMemo(() => ({ ...defaultStyles, ...styles }), [styles]);
91
+ const t = useMemo(() => ({ ...defaultLabels, ...labels }), [labels]);
92
+ const [query, setQuery] = useState(initialQuery ?? "");
93
+ // Reset the search each time the modal (re-)opens — adjusted during render
94
+ // rather than in an effect, since it only needs to happen once per
95
+ // visible-transition and must be visible in the very first render after it.
96
+ const [prevVisible, setPrevVisible] = useState(visible);
97
+ if (visible !== prevVisible) {
98
+ setPrevVisible(visible);
99
+ if (visible)
100
+ setQuery(initialQuery ?? "");
101
+ }
102
+ const isSearching = query.trim().length > 0;
103
+ const filtered = useMemo(() => {
104
+ if (!isSearching)
105
+ return items;
106
+ if (filterItems)
107
+ return filterItems(items, query);
108
+ const q = query.trim().toLowerCase();
109
+ return items.filter((item) => item.label.toLowerCase().includes(q));
110
+ }, [items, query, filterItems, isSearching]);
111
+ // Sections sorted by title: stable order, independent of the source
112
+ // groups' order. No sections during a search (results ranked by relevance,
113
+ // not by group), nor if no item has a group (uncategorized pickers,
114
+ // unchanged behavior).
115
+ const sections = useMemo(() => {
116
+ if (isSearching || !filtered.some((item) => item.group)) {
117
+ return [{ title: null, data: filtered }];
118
+ }
119
+ const byGroup = new Map();
120
+ for (const item of filtered) {
121
+ const key = item.group ?? t.otherGroupLabel;
122
+ const group = byGroup.get(key);
123
+ if (group)
124
+ group.push(item);
125
+ else
126
+ byGroup.set(key, [item]);
127
+ }
128
+ function sortIndex(groupTitle) {
129
+ const index = groupOrder?.indexOf(groupTitle) ?? -1;
130
+ return index === -1 ? (groupOrder?.length ?? 0) : index;
131
+ }
132
+ return [...byGroup.entries()]
133
+ .sort(([a], [b]) => sortIndex(a) - sortIndex(b) || a.localeCompare(b))
134
+ .map(([sectionTitle, data]) => ({ title: sectionTitle, data }));
135
+ }, [filtered, isSearching, groupOrder, t.otherGroupLabel]);
136
+ return (_jsx(Modal, { visible: visible, animationType: "slide", onRequestClose: onClose, children: _jsxs(View, { style: merged.container, children: [_jsxs(View, { style: merged.header, children: [_jsx(Text, { style: merged.title, children: title }), _jsx(Pressable, { onPress: onClose, hitSlop: 12, accessibilityRole: "button", accessibilityLabel: t.close, children: _jsx(Text, { style: merged.close, children: t.close }) })] }), _jsxs(View, { style: merged.searchRow, children: [_jsx(TextInput, { style: merged.search, placeholder: t.searchPlaceholder, placeholderTextColor: merged.placeholderTextColor, value: query, onChangeText: setQuery, autoFocus: true, accessibilityLabel: t.searchAccessibilityLabel }), _jsx(VoiceSearchButton, { onResult: setQuery, accessibilityLabel: t.voiceSearchAccessibilityLabel })] }), _jsx(SectionList, { sections: sections, keyExtractor: (item) => String(item.id), keyboardShouldPersistTaps: "handled", stickySectionHeadersEnabled: false, ListHeaderComponent: extraActions && extraActions.length > 0 ? (_jsx(View, { style: merged.extraActions, children: extraActions.map((action) => (_jsxs(Pressable, { style: merged.row, onPress: action.onPress, accessibilityRole: "button", accessibilityLabel: action.label, children: [_jsx(Ionicons, { name: "add-circle-outline", size: 20, color: merged.iconColor }), _jsx(Text, { style: [merged.rowLabel, merged.extraActionLabel], children: action.label })] }, action.label))) })) : null, ListEmptyComponent: _jsx(Text, { style: merged.empty, children: emptyMessage ?? t.defaultEmptyMessage }), renderSectionHeader: ({ section: { title: sectionTitle }, }) => sectionTitle ? (_jsx(Text, { style: merged.sectionHeader, children: sectionTitle })) : null, renderItem: ({ item }) => (_jsxs(Pressable, { style: merged.row, onPress: () => {
137
+ onSelect(item);
138
+ setQuery("");
139
+ }, accessibilityRole: "button", accessibilityLabel: item.subtitle ? `${item.label}, ${item.subtitle}` : item.label, children: [item.imageUri ? (_jsx(Image, { source: { uri: item.imageUri }, style: merged.rowThumbnail, accessibilityIgnoresInvertColors: true })) : item.imageUri === null ? (_jsx(View, { style: merged.rowThumbnailPlaceholder, children: _jsx(Ionicons, { name: "cube-outline", size: 18, color: merged.iconColor }) })) : null, _jsxs(View, { style: { flex: 1 }, children: [_jsx(Text, { style: merged.rowLabel, children: item.label }), item.subtitle ? (_jsx(Text, { style: merged.rowSubtitle, children: item.subtitle })) : null] })] })) })] }) }));
140
+ }
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from "react";
2
+ import type { StyleProp, TextStyle, ViewStyle } from "react-native";
3
+ export interface SwipeableRowStyles {
4
+ deleteAction?: StyleProp<ViewStyle>;
5
+ deleteActionText?: StyleProp<TextStyle>;
6
+ deleteIconColor?: string;
7
+ }
8
+ export interface SwipeableRowProps {
9
+ children: ReactNode;
10
+ onDelete: () => void;
11
+ deleteLabel: string;
12
+ deleteText?: string;
13
+ styles?: SwipeableRowStyles;
14
+ }
15
+ export declare function SwipeableRow({ children, onDelete, deleteLabel, deleteText, styles, }: SwipeableRowProps): import("react").JSX.Element;
16
+ //# sourceMappingURL=SwipeableRow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SwipeableRow.d.ts","sourceRoot":"","sources":["../src/SwipeableRow.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAKpE,MAAM,WAAW,kBAAkB;IACjC,YAAY,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACpC,gBAAgB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACxC,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,SAAS,CAAC;IACpB,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,kBAAkB,CAAC;CAC7B;AAiBD,wBAAgB,YAAY,CAAC,EAC3B,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,UAAqB,EACrB,MAAM,GACP,EAAE,iBAAiB,+BA6BnB"}
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef } from "react";
3
+ import { Pressable, Text } from "react-native";
4
+ import { Swipeable } from "react-native-gesture-handler";
5
+ import { Ionicons } from "@expo/vector-icons";
6
+ const defaultStyles = {
7
+ deleteAction: {
8
+ backgroundColor: "#dc2626",
9
+ justifyContent: "center",
10
+ alignItems: "center",
11
+ width: 84,
12
+ marginBottom: 10,
13
+ borderRadius: 12,
14
+ gap: 2,
15
+ },
16
+ deleteActionText: { color: "#ffffff", fontSize: 11, fontWeight: "600" },
17
+ deleteIconColor: "#ffffff",
18
+ };
19
+ // Swipe a list row left to reveal a delete button, on top of a tap to edit it.
20
+ export function SwipeableRow({ children, onDelete, deleteLabel, deleteText = "Delete", styles, }) {
21
+ const merged = { ...defaultStyles, ...styles };
22
+ const swipeableRef = useRef(null);
23
+ return (_jsx(Swipeable, { ref: swipeableRef, renderRightActions: () => (_jsxs(Pressable, { style: merged.deleteAction, onPress: () => {
24
+ swipeableRef.current?.close();
25
+ onDelete();
26
+ }, accessibilityRole: "button", accessibilityLabel: deleteLabel, children: [_jsx(Ionicons, { name: "trash-outline", size: 22, color: merged.deleteIconColor }), _jsx(Text, { style: merged.deleteActionText, children: deleteText })] })), children: children }));
27
+ }
@@ -0,0 +1,16 @@
1
+ import type { ImageStyle, StyleProp, ViewStyle } from "react-native";
2
+ import { Ionicons } from "@expo/vector-icons";
3
+ export interface ThumbnailStyles {
4
+ thumbnail?: StyleProp<ImageStyle>;
5
+ placeholder?: StyleProp<ViewStyle>;
6
+ iconColor?: string;
7
+ }
8
+ export interface ThumbnailProps {
9
+ photoUri: string | null;
10
+ /** Icon shown in place of a photo, e.g. "cube-outline", "restaurant-outline". */
11
+ placeholderIcon: keyof typeof Ionicons.glyphMap;
12
+ size?: number;
13
+ styles?: ThumbnailStyles;
14
+ }
15
+ export declare function Thumbnail({ photoUri, placeholderIcon, size, styles, }: ThumbnailProps): import("react").JSX.Element;
16
+ //# sourceMappingURL=Thumbnail.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Thumbnail.d.ts","sourceRoot":"","sources":["../src/Thumbnail.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAErE,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAE9C,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,EAAE,SAAS,CAAC,UAAU,CAAC,CAAC;IAClC,WAAW,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACnC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,iFAAiF;IACjF,eAAe,EAAE,MAAM,OAAO,QAAQ,CAAC,QAAQ,CAAC;IAChD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAgBD,wBAAgB,SAAS,CAAC,EACxB,QAAQ,EACR,eAAe,EACf,IAAmB,EACnB,MAAM,GACP,EAAE,cAAc,+BAuBhB"}
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Image, View } from "react-native";
3
+ import { Ionicons } from "@expo/vector-icons";
4
+ const DEFAULT_SIZE = 48;
5
+ const defaultStyles = {
6
+ thumbnail: { borderRadius: 10, backgroundColor: "#f3f4f6" },
7
+ placeholder: {
8
+ borderRadius: 10,
9
+ backgroundColor: "#f3f4f6",
10
+ alignItems: "center",
11
+ justifyContent: "center",
12
+ },
13
+ iconColor: "#6b7280",
14
+ };
15
+ // List-row thumbnail (photo, or a placeholder icon when there is none).
16
+ export function Thumbnail({ photoUri, placeholderIcon, size = DEFAULT_SIZE, styles, }) {
17
+ const merged = { ...defaultStyles, ...styles };
18
+ const dimensions = { width: size, height: size };
19
+ if (photoUri) {
20
+ return (_jsx(Image, { source: { uri: photoUri }, style: [dimensions, merged.thumbnail], accessibilityIgnoresInvertColors: true }));
21
+ }
22
+ return (_jsx(View, { style: [dimensions, merged.placeholder], children: _jsx(Ionicons, { name: placeholderIcon, size: Math.round(size * 0.46), color: merged.iconColor }) }));
23
+ }
@@ -0,0 +1,14 @@
1
+ export interface VoiceSearchButtonStyles {
2
+ iconColor?: string;
3
+ iconColorActive?: string;
4
+ }
5
+ export interface VoiceSearchButtonProps {
6
+ onResult: (transcript: string) => void;
7
+ accessibilityLabel?: string;
8
+ stopAccessibilityLabel?: string;
9
+ /** BCP 47 locale passed to the speech recognizer. */
10
+ lang?: string;
11
+ styles?: VoiceSearchButtonStyles;
12
+ }
13
+ export declare function VoiceSearchButton({ onResult, accessibilityLabel, stopAccessibilityLabel, lang, styles, }: VoiceSearchButtonProps): import("react").JSX.Element;
14
+ //# sourceMappingURL=VoiceSearchButton.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VoiceSearchButton.d.ts","sourceRoot":"","sources":["../src/VoiceSearchButton.tsx"],"names":[],"mappings":"AAQA,MAAM,WAAW,uBAAuB;IACtC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,uBAAuB,CAAC;CAClC;AAaD,wBAAgB,iBAAiB,CAAC,EAChC,QAAQ,EACR,kBAAmC,EACnC,sBAA4C,EAC5C,IAAc,EACd,MAAM,GACP,EAAE,sBAAsB,+BAwDxB"}
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useRef, useState } from "react";
3
+ import { Pressable } from "react-native";
4
+ import { Ionicons } from "@expo/vector-icons";
5
+ import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent, } from "expo-speech-recognition";
6
+ const defaultStyles = {
7
+ iconColor: "#2563eb",
8
+ iconColorActive: "#dc2626",
9
+ };
10
+ // Microphone button to dictate a search instead of typing it. Recognition
11
+ // events are global to the native module (not scoped to one instance): more
12
+ // than one button can be mounted at the same time (e.g. a name field plus a
13
+ // search picker on the same screen). `requestedRef` distinguishes the button
14
+ // that started THIS session from other mounted instances, so only it reacts
15
+ // to the result instead of all of them.
16
+ export function VoiceSearchButton({ onResult, accessibilityLabel = "Voice search", stopAccessibilityLabel = "Stop voice search", lang = "en-US", styles, }) {
17
+ const merged = { ...defaultStyles, ...styles };
18
+ const [isListening, setIsListening] = useState(false);
19
+ const requestedRef = useRef(false);
20
+ useSpeechRecognitionEvent("start", () => {
21
+ if (requestedRef.current)
22
+ setIsListening(true);
23
+ });
24
+ useSpeechRecognitionEvent("end", () => {
25
+ if (requestedRef.current) {
26
+ setIsListening(false);
27
+ requestedRef.current = false;
28
+ }
29
+ });
30
+ useSpeechRecognitionEvent("error", () => {
31
+ if (requestedRef.current) {
32
+ setIsListening(false);
33
+ requestedRef.current = false;
34
+ }
35
+ });
36
+ useSpeechRecognitionEvent("result", (event) => {
37
+ if (!requestedRef.current)
38
+ return;
39
+ const transcript = event.results[0]?.transcript;
40
+ if (transcript)
41
+ onResult(transcript);
42
+ });
43
+ async function handlePress() {
44
+ if (isListening) {
45
+ ExpoSpeechRecognitionModule.stop();
46
+ return;
47
+ }
48
+ const permission = await ExpoSpeechRecognitionModule.requestPermissionsAsync();
49
+ if (!permission.granted)
50
+ return;
51
+ requestedRef.current = true;
52
+ ExpoSpeechRecognitionModule.start({ lang, interimResults: false });
53
+ }
54
+ return (_jsx(Pressable, { onPress: () => {
55
+ void handlePress();
56
+ }, hitSlop: 10, accessibilityRole: "button", accessibilityLabel: isListening ? stopAccessibilityLabel : accessibilityLabel, children: _jsx(Ionicons, { name: isListening ? "mic" : "mic-outline", size: 20, color: isListening ? merged.iconColorActive : merged.iconColor }) }));
57
+ }
@@ -0,0 +1,7 @@
1
+ export interface ConfirmDestructiveOptions {
2
+ message?: string;
3
+ cancelLabel?: string;
4
+ confirmLabel?: string;
5
+ }
6
+ export declare function confirmDestructive(title: string, onConfirm: () => void, options?: ConfirmDestructiveOptions): void;
7
+ //# sourceMappingURL=confirmDestructive.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"confirmDestructive.d.ts","sourceRoot":"","sources":["../src/confirmDestructive.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,yBAAyB;IACxC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAQD,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,IAAI,EACrB,OAAO,GAAE,yBAA8B,QAWxC"}
@@ -0,0 +1,13 @@
1
+ import { Alert } from "react-native";
2
+ const DEFAULT_MESSAGE = "Cette action est définitive.";
3
+ const DEFAULT_CANCEL_LABEL = "Annuler";
4
+ const DEFAULT_CONFIRM_LABEL = "Supprimer";
5
+ // Confirmation destructive générique (titre + message + Annuler/Confirmer),
6
+ // pour toute action irréversible (suppression, réinitialisation...).
7
+ export function confirmDestructive(title, onConfirm, options = {}) {
8
+ const { message = DEFAULT_MESSAGE, cancelLabel = DEFAULT_CANCEL_LABEL, confirmLabel = DEFAULT_CONFIRM_LABEL, } = options;
9
+ Alert.alert(title, message, [
10
+ { text: cancelLabel, style: "cancel" },
11
+ { text: confirmLabel, style: "destructive", onPress: onConfirm },
12
+ ]);
13
+ }
@@ -0,0 +1,2 @@
1
+ export declare function useDebouncedChange(values: unknown[], delayMs: number, callback: () => void): void;
2
+ //# sourceMappingURL=useDebouncedChange.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useDebouncedChange.d.ts","sourceRoot":"","sources":["../src/useDebouncedChange.ts"],"names":[],"mappings":"AASA,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,OAAO,EAAE,EACjB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,IAAI,QAkBrB"}
@@ -0,0 +1,24 @@
1
+ import { useEffect, useRef } from "react";
2
+ // Déclenche `callback` `delayMs` après le dernier changement parmi `values`,
3
+ // en ignorant tout rendu tant qu'au moins une valeur vaut `undefined` (ex: une
4
+ // donnée pas encore chargée) et le tout premier rendu où elles le sont toutes
5
+ // (pour éviter un déclenchement au montage). Le délai est annulé si une
6
+ // nouvelle valeur arrive avant qu'il n'expire (vrai debounce). `values` doit
7
+ // avoir la même longueur à chaque rendu, comme pour un tableau de
8
+ // dépendances de useEffect classique.
9
+ export function useDebouncedChange(values, delayMs, callback) {
10
+ const hasInitializedRef = useRef(false);
11
+ useEffect(() => {
12
+ if (values.some((value) => value === undefined))
13
+ return;
14
+ if (!hasInitializedRef.current) {
15
+ hasInitializedRef.current = true;
16
+ return;
17
+ }
18
+ const timeout = setTimeout(callback, delayMs);
19
+ return () => {
20
+ clearTimeout(timeout);
21
+ };
22
+ // eslint-disable-next-line @eslint-react/exhaustive-deps, react-hooks/exhaustive-deps -- `values` IS the dependency list, by design (caller-provided, variable length not expected to change).
23
+ }, values);
24
+ }
@@ -0,0 +1,5 @@
1
+ export declare function useSubmitGuard(): {
2
+ isSaving: boolean;
3
+ guard: (action: () => Promise<void>) => Promise<void>;
4
+ };
5
+ //# sourceMappingURL=useSubmitGuard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSubmitGuard.d.ts","sourceRoot":"","sources":["../src/useSubmitGuard.ts"],"names":[],"mappings":"AAKA,wBAAgB,cAAc;;oBAGC,MAAM,OAAO,CAAC,IAAI,CAAC;EAWjD"}
@@ -0,0 +1,19 @@
1
+ import { useState } from "react";
2
+ // Empêche un second appel pendant qu'un premier est encore en cours (ex:
3
+ // double-tap sur un bouton "Enregistrer" avant qu'il n'ait eu le temps de se
4
+ // désactiver, qui créerait plusieurs soumissions identiques).
5
+ export function useSubmitGuard() {
6
+ const [isSaving, setIsSaving] = useState(false);
7
+ async function guard(action) {
8
+ if (isSaving)
9
+ return;
10
+ setIsSaving(true);
11
+ try {
12
+ await action();
13
+ }
14
+ finally {
15
+ setIsSaving(false);
16
+ }
17
+ }
18
+ return { isSaving, guard };
19
+ }
@@ -0,0 +1,2 @@
1
+ export declare function escapeCsvField(value: string): string;
2
+ //# sourceMappingURL=escapeCsvField.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"escapeCsvField.d.ts","sourceRoot":"","sources":["../../src/utils/escapeCsvField.ts"],"names":[],"mappings":"AAGA,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAGpD"}
@@ -0,0 +1,8 @@
1
+ // Échappe un champ CSV (RFC 4180) : entoure de guillemets et double les
2
+ // guillemets internes si la valeur contient un guillemet, un point-virgule
3
+ // (délimiteur) ou un saut de ligne — sinon la valeur est renvoyée telle quelle.
4
+ export function escapeCsvField(value) {
5
+ if (!/[";\n]/.test(value))
6
+ return value;
7
+ return `"${value.replace(/"/g, '""')}"`;
8
+ }
@@ -0,0 +1,2 @@
1
+ export declare function escapeHtml(text: string): string;
2
+ //# sourceMappingURL=escapeHtml.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"escapeHtml.d.ts","sourceRoot":"","sources":["../../src/utils/escapeHtml.ts"],"names":[],"mappings":"AAEA,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAM/C"}
@@ -0,0 +1,9 @@
1
+ // Échappement HTML basique (esperluette, chevrons, guillemets) pour insérer
2
+ // du texte utilisateur dans un gabarit HTML sans dépendance de templating.
3
+ export function escapeHtml(text) {
4
+ return text
5
+ .replace(/&/g, "&amp;")
6
+ .replace(/</g, "&lt;")
7
+ .replace(/>/g, "&gt;")
8
+ .replace(/"/g, "&quot;");
9
+ }
@@ -0,0 +1,2 @@
1
+ export declare function formatCsvNumber(value: number, decimals?: number): string;
2
+ //# sourceMappingURL=formatCsvNumber.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formatCsvNumber.d.ts","sourceRoot":"","sources":["../../src/utils/formatCsvNumber.ts"],"names":[],"mappings":"AAGA,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,SAAI,GAAG,MAAM,CAEnE"}
@@ -0,0 +1,6 @@
1
+ // Nombre brut (pas de suffixe d'unité) pour rester exploitable dans un
2
+ // tableur (somme, moyenne...), avec la virgule décimale attendue par Excel
3
+ // en locale française plutôt que le point de JS.
4
+ export function formatCsvNumber(value, decimals = 1) {
5
+ return value.toFixed(decimals).replace(".", ",");
6
+ }
@@ -0,0 +1,6 @@
1
+ export interface RecentIdRow {
2
+ id: number | null;
3
+ occurredAt: string;
4
+ }
5
+ export declare function getMostRecentIds(rows: RecentIdRow[], limit?: number): number[];
6
+ //# sourceMappingURL=getMostRecentIds.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"getMostRecentIds.d.ts","sourceRoot":"","sources":["../../src/utils/getMostRecentIds.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB;AAQD,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,WAAW,EAAE,EACnB,KAAK,SAAgB,GACpB,MAAM,EAAE,CAcV"}
@@ -0,0 +1,19 @@
1
+ const DEFAULT_LIMIT = 5;
2
+ // Ids distincts les plus récents, du plus récent au plus ancien — utile pour
3
+ // un sélecteur "récents" (aliment, récipient, ou tout autre entité datée).
4
+ // `id` peut être null (référence supprimée depuis) : ignoré, on ne peut plus
5
+ // le proposer.
6
+ export function getMostRecentIds(rows, limit = DEFAULT_LIMIT) {
7
+ const sorted = [...rows].sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
8
+ const seen = new Set();
9
+ const result = [];
10
+ for (const row of sorted) {
11
+ if (row.id == null || seen.has(row.id))
12
+ continue;
13
+ seen.add(row.id);
14
+ result.push(row.id);
15
+ if (result.length >= limit)
16
+ break;
17
+ }
18
+ return result;
19
+ }
@@ -0,0 +1,3 @@
1
+ export type PeriodFilter = "all" | "today" | "7d" | "30d";
2
+ export declare function getPeriodStartMs(period: PeriodFilter, now?: Date): number | null;
3
+ //# sourceMappingURL=getPeriodStartMs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"getPeriodStartMs.d.ts","sourceRoot":"","sources":["../../src/utils/getPeriodStartMs.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,OAAO,GAAG,IAAI,GAAG,KAAK,CAAC;AAK1D,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,YAAY,EACpB,GAAG,OAAa,GACf,MAAM,GAAG,IAAI,CASf"}
@@ -0,0 +1,14 @@
1
+ // Timestamp (ms) à partir duquel un enregistrement est inclus pour cette
2
+ // période, ou null pour "all" (pas de filtre de date). "today" part du début
3
+ // du jour calendaire courant, pas d'il y a 24h glissantes.
4
+ export function getPeriodStartMs(period, now = new Date()) {
5
+ if (period === "all")
6
+ return null;
7
+ if (period === "today") {
8
+ const startOfDay = new Date(now);
9
+ startOfDay.setHours(0, 0, 0, 0);
10
+ return startOfDay.getTime();
11
+ }
12
+ const days = period === "7d" ? 7 : 30;
13
+ return now.getTime() - days * 24 * 60 * 60 * 1000;
14
+ }
@@ -0,0 +1,2 @@
1
+ export declare function nextInCycle(ids: number[], currentId: number | null): number | null;
2
+ //# sourceMappingURL=nextInCycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nextInCycle.d.ts","sourceRoot":"","sources":["../../src/utils/nextInCycle.ts"],"names":[],"mappings":"AAGA,wBAAgB,WAAW,CACzB,GAAG,EAAE,MAAM,EAAE,EACb,SAAS,EAAE,MAAM,GAAG,IAAI,GACvB,MAAM,GAAG,IAAI,CAIf"}
@@ -0,0 +1,9 @@
1
+ // Élément suivant dans une liste courte (ex: cycler sur tap entre quelques
2
+ // éléments récents). Reprend au début si l'actuel n'y est plus (supprimé, ou
3
+ // hors de la liste) ou si on est en bout de liste.
4
+ export function nextInCycle(ids, currentId) {
5
+ if (ids.length === 0)
6
+ return null;
7
+ const currentIndex = currentId == null ? -1 : ids.indexOf(currentId);
8
+ return ids[(currentIndex + 1) % ids.length] ?? null;
9
+ }
@@ -0,0 +1,2 @@
1
+ export declare function normalizeForSearch(text: string): string;
2
+ //# sourceMappingURL=normalizeForSearch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalizeForSearch.d.ts","sourceRoot":"","sources":["../../src/utils/normalizeForSearch.ts"],"names":[],"mappings":"AASA,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMvD"}
@@ -0,0 +1,15 @@
1
+ // Les ligatures œ/æ ne se décomposent PAS via normalize("NFD") (contrairement
2
+ // aux lettres accentuées) : ce sont des lettres Unicode à part entière, pas
3
+ // une base + un signe combinant. Sans ce remplacement explicite, chercher
4
+ // "Œuf" (avec la vraie ligature) ne trouve jamais "Oeuf" dans une base qui
5
+ // l'orthographie sans ligature (comme la plupart des bases de données texte).
6
+ function expandLigatures(text) {
7
+ return text.replace(/[œŒ]/g, "oe").replace(/[æÆ]/g, "ae");
8
+ }
9
+ export function normalizeForSearch(text) {
10
+ return expandLigatures(text)
11
+ .normalize("NFD")
12
+ .replace(/[̀-ͯ]/g, "")
13
+ .toLowerCase()
14
+ .trim();
15
+ }
@@ -0,0 +1,2 @@
1
+ export declare function rankByNameMatch<T>(items: T[], query: string, getName: (item: T) => string): T[];
2
+ //# sourceMappingURL=rankByNameMatch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rankByNameMatch.d.ts","sourceRoot":"","sources":["../../src/utils/rankByNameMatch.ts"],"names":[],"mappings":"AAsBA,wBAAgB,eAAe,CAAC,CAAC,EAC/B,KAAK,EAAE,CAAC,EAAE,EACV,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,GAC3B,CAAC,EAAE,CAiBL"}
@@ -0,0 +1,33 @@
1
+ import { normalizeForSearch } from "./normalizeForSearch.js";
2
+ // Cache le nom normalisé de chaque élément par référence d'objet : utile pour
3
+ // des listes figées (constantes de module) cherchées à chaque frappe clavier
4
+ // dans un champ de recherche, pour éviter de renormaliser tous les noms à
5
+ // chaque appel.
6
+ const normalizedNameCache = new WeakMap();
7
+ function getNormalizedName(item, getName) {
8
+ if (typeof item !== "object" || item === null)
9
+ return normalizeForSearch(getName(item));
10
+ const cached = normalizedNameCache.get(item);
11
+ if (cached !== undefined)
12
+ return cached;
13
+ const normalized = normalizeForSearch(getName(item));
14
+ normalizedNameCache.set(item, normalized);
15
+ return normalized;
16
+ }
17
+ // Classe des éléments par pertinence vis-à-vis d'une recherche texte :
18
+ // position du match dans le nom (plus tôt = plus pertinent), puis longueur
19
+ // du nom. Générique pour être réutilisable par n'importe quelle liste
20
+ // d'éléments ayant un nom affichable.
21
+ export function rankByNameMatch(items, query, getName) {
22
+ const normalizedQuery = normalizeForSearch(query);
23
+ const matches = [];
24
+ for (const item of items) {
25
+ const matchIndex = getNormalizedName(item, getName).indexOf(normalizedQuery);
26
+ if (matchIndex !== -1) {
27
+ matches.push({ item, matchIndex });
28
+ }
29
+ }
30
+ matches.sort((a, b) => a.matchIndex - b.matchIndex ||
31
+ getName(a.item).length - getName(b.item).length);
32
+ return matches.map((m) => m.item);
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forthtilliath/react-native-kit",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -23,15 +23,23 @@
23
23
  "@forthtilliath/expo-release-updates": "0.2.0"
24
24
  },
25
25
  "peerDependencies": {
26
+ "@expo/vector-icons": ">=15.0.0",
27
+ "expo-image-picker": ">=54.0.0",
28
+ "expo-speech-recognition": ">=1.0.0",
26
29
  "react": ">=19",
27
- "react-native": ">=0.81"
30
+ "react-native": ">=0.81",
31
+ "react-native-gesture-handler": ">=2.20.0"
28
32
  },
29
33
  "devDependencies": {
34
+ "@expo/vector-icons": "^15.0.2",
30
35
  "@types/react": "^19.2.17",
31
36
  "@types/react-test-renderer": "^19.1.0",
32
37
  "eslint": "10.7.0",
38
+ "expo-image-picker": "~57.0.7",
39
+ "expo-speech-recognition": "^56.0.1",
33
40
  "react": "^19.2.7",
34
41
  "react-native": "0.86.0",
42
+ "react-native-gesture-handler": "~2.32.0",
35
43
  "react-test-renderer": "19.2.3",
36
44
  "typescript": "^5.9.3",
37
45
  "vitest": "^4.1.10",