@forthtilliath/react-native-kit 0.3.0 → 0.5.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.
- package/README.md +89 -2
- package/dist/PhotoPicker.d.ts +34 -0
- package/dist/PhotoPicker.d.ts.map +1 -0
- package/dist/PhotoPicker.js +106 -0
- package/dist/PickerModal.d.ts +68 -0
- package/dist/PickerModal.d.ts.map +1 -0
- package/dist/PickerModal.js +141 -0
- package/dist/SwipeableRow.d.ts +16 -0
- package/dist/SwipeableRow.d.ts.map +1 -0
- package/dist/SwipeableRow.js +27 -0
- package/dist/Thumbnail.d.ts +16 -0
- package/dist/Thumbnail.d.ts.map +1 -0
- package/dist/Thumbnail.js +23 -0
- package/dist/VoiceSearchButton.d.ts +14 -0
- package/dist/VoiceSearchButton.d.ts.map +1 -0
- package/dist/VoiceSearchButton.js +57 -0
- package/package.json +12 -4
package/README.md
CHANGED
|
@@ -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,93 @@ 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
|
+
|
|
73
160
|
### `useSubmitGuard()`
|
|
74
161
|
|
|
75
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.
|
|
@@ -158,4 +245,4 @@ Built to `dist/` (see the `exports` field in `package.json`), so run `pnpm run b
|
|
|
158
245
|
|
|
159
246
|
### Testing note
|
|
160
247
|
|
|
161
|
-
`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,68 @@
|
|
|
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
|
+
extraActionIconColor?: string;
|
|
31
|
+
rowThumbnailPlaceholderIconColor?: string;
|
|
32
|
+
placeholderTextColor?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface PickerModalLabels {
|
|
35
|
+
close?: string;
|
|
36
|
+
searchPlaceholder?: string;
|
|
37
|
+
searchAccessibilityLabel?: string;
|
|
38
|
+
voiceSearchAccessibilityLabel?: string;
|
|
39
|
+
defaultEmptyMessage?: string;
|
|
40
|
+
otherGroupLabel?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface PickerModalProps {
|
|
43
|
+
visible: boolean;
|
|
44
|
+
title: string;
|
|
45
|
+
items: PickerItem[];
|
|
46
|
+
onSelect: (item: PickerItem) => void;
|
|
47
|
+
onClose: () => void;
|
|
48
|
+
emptyMessage?: string;
|
|
49
|
+
/** Initial search text on open; defaults to empty. */
|
|
50
|
+
initialQuery?: string;
|
|
51
|
+
/** Custom filter (e.g. relevance ranking); defaults to a case-insensitive substring match. */
|
|
52
|
+
filterItems?: (items: PickerItem[], query: string) => PickerItem[];
|
|
53
|
+
/** "Add" actions always visible at the top, even when the search matches nothing. */
|
|
54
|
+
extraActions?: {
|
|
55
|
+
label: string;
|
|
56
|
+
onPress: () => void;
|
|
57
|
+
}[];
|
|
58
|
+
/**
|
|
59
|
+
* Explicit section order (e.g. "Recent" before others); sections default
|
|
60
|
+
* to alphabetical order by title. A group absent from this list is placed
|
|
61
|
+
* after, alphabetically.
|
|
62
|
+
*/
|
|
63
|
+
groupOrder?: string[];
|
|
64
|
+
labels?: PickerModalLabels;
|
|
65
|
+
styles?: PickerModalStyles;
|
|
66
|
+
}
|
|
67
|
+
export declare function PickerModal({ visible, title, items, onSelect, onClose, emptyMessage, initialQuery, filterItems, extraActions, groupOrder, labels, styles, }: PickerModalProps): import("react").JSX.Element;
|
|
68
|
+
//# 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,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,gCAAgC,CAAC,EAAE,MAAM,CAAC;IAC1C,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;AAwFD,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,141 @@
|
|
|
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
|
+
extraActionIconColor: "#2563eb",
|
|
77
|
+
rowThumbnailPlaceholderIconColor: "#6b7280",
|
|
78
|
+
placeholderTextColor: "#6b7280",
|
|
79
|
+
};
|
|
80
|
+
const defaultLabels = {
|
|
81
|
+
close: "Fermer",
|
|
82
|
+
searchPlaceholder: "Rechercher…",
|
|
83
|
+
searchAccessibilityLabel: "Rechercher",
|
|
84
|
+
voiceSearchAccessibilityLabel: "Dicter la recherche",
|
|
85
|
+
defaultEmptyMessage: "Aucun résultat.",
|
|
86
|
+
otherGroupLabel: "Autres",
|
|
87
|
+
};
|
|
88
|
+
// Full-screen picker: search (typed or dictated), optional sections, and
|
|
89
|
+
// "add" actions always visible above the results.
|
|
90
|
+
export function PickerModal({ visible, title, items, onSelect, onClose, emptyMessage, initialQuery, filterItems, extraActions, groupOrder, labels, styles, }) {
|
|
91
|
+
const merged = useMemo(() => ({ ...defaultStyles, ...styles }), [styles]);
|
|
92
|
+
const t = useMemo(() => ({ ...defaultLabels, ...labels }), [labels]);
|
|
93
|
+
const [query, setQuery] = useState(initialQuery ?? "");
|
|
94
|
+
// Reset the search each time the modal (re-)opens — adjusted during render
|
|
95
|
+
// rather than in an effect, since it only needs to happen once per
|
|
96
|
+
// visible-transition and must be visible in the very first render after it.
|
|
97
|
+
const [prevVisible, setPrevVisible] = useState(visible);
|
|
98
|
+
if (visible !== prevVisible) {
|
|
99
|
+
setPrevVisible(visible);
|
|
100
|
+
if (visible)
|
|
101
|
+
setQuery(initialQuery ?? "");
|
|
102
|
+
}
|
|
103
|
+
const isSearching = query.trim().length > 0;
|
|
104
|
+
const filtered = useMemo(() => {
|
|
105
|
+
if (!isSearching)
|
|
106
|
+
return items;
|
|
107
|
+
if (filterItems)
|
|
108
|
+
return filterItems(items, query);
|
|
109
|
+
const q = query.trim().toLowerCase();
|
|
110
|
+
return items.filter((item) => item.label.toLowerCase().includes(q));
|
|
111
|
+
}, [items, query, filterItems, isSearching]);
|
|
112
|
+
// Sections sorted by title: stable order, independent of the source
|
|
113
|
+
// groups' order. No sections during a search (results ranked by relevance,
|
|
114
|
+
// not by group), nor if no item has a group (uncategorized pickers,
|
|
115
|
+
// unchanged behavior).
|
|
116
|
+
const sections = useMemo(() => {
|
|
117
|
+
if (isSearching || !filtered.some((item) => item.group)) {
|
|
118
|
+
return [{ title: null, data: filtered }];
|
|
119
|
+
}
|
|
120
|
+
const byGroup = new Map();
|
|
121
|
+
for (const item of filtered) {
|
|
122
|
+
const key = item.group ?? t.otherGroupLabel;
|
|
123
|
+
const group = byGroup.get(key);
|
|
124
|
+
if (group)
|
|
125
|
+
group.push(item);
|
|
126
|
+
else
|
|
127
|
+
byGroup.set(key, [item]);
|
|
128
|
+
}
|
|
129
|
+
function sortIndex(groupTitle) {
|
|
130
|
+
const index = groupOrder?.indexOf(groupTitle) ?? -1;
|
|
131
|
+
return index === -1 ? (groupOrder?.length ?? 0) : index;
|
|
132
|
+
}
|
|
133
|
+
return [...byGroup.entries()]
|
|
134
|
+
.sort(([a], [b]) => sortIndex(a) - sortIndex(b) || a.localeCompare(b))
|
|
135
|
+
.map(([sectionTitle, data]) => ({ title: sectionTitle, data }));
|
|
136
|
+
}, [filtered, isSearching, groupOrder, t.otherGroupLabel]);
|
|
137
|
+
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.extraActionIconColor }), _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: () => {
|
|
138
|
+
onSelect(item);
|
|
139
|
+
setQuery("");
|
|
140
|
+
}, 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.rowThumbnailPlaceholderIconColor }) })) : 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] })] })) })] }) }));
|
|
141
|
+
}
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forthtilliath/react-native-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -23,20 +23,28 @@
|
|
|
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",
|
|
38
|
-
"@forthtilliath/
|
|
39
|
-
"@forthtilliath/
|
|
46
|
+
"@forthtilliath/typescript-config": "0.0.0",
|
|
47
|
+
"@forthtilliath/eslint-config": "0.0.0"
|
|
40
48
|
},
|
|
41
49
|
"scripts": {
|
|
42
50
|
"lint": "eslint . --max-warnings 0",
|