@lotics/ui 3.2.0 → 3.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.
- package/package.json +3 -1
- package/src/alert.tsx +8 -2
- package/src/empty_state.tsx +23 -1
- package/src/file_gallery_modal.tsx +71 -9
- package/src/file_preview.tsx +4 -11
- package/src/file_preview.web.tsx +10 -8
- package/src/file_preview_types.ts +2 -0
- package/src/icon.tsx +2 -0
- package/src/image_gallery.tsx +61 -18
- package/src/pagination.tsx +37 -7
- package/src/rotatable_image.tsx +56 -0
- package/src/sort_header.tsx +24 -3
- package/src/table.tsx +5 -3
- package/src/use_image_rotation.ts +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"./file_preview_types": "./src/file_preview_types.ts",
|
|
20
20
|
"./file_gallery_modal": "./src/file_gallery_modal.tsx",
|
|
21
21
|
"./image_gallery": "./src/image_gallery.tsx",
|
|
22
|
+
"./rotatable_image": "./src/rotatable_image.tsx",
|
|
23
|
+
"./use_image_rotation": "./src/use_image_rotation.ts",
|
|
22
24
|
"./file_thumbnail_grid": "./src/file_thumbnail_grid.tsx",
|
|
23
25
|
"./pagination": "./src/pagination.tsx",
|
|
24
26
|
"./bar_chart": "./src/bar_chart.tsx",
|
package/src/alert.tsx
CHANGED
|
@@ -135,14 +135,20 @@ class Alert {
|
|
|
135
135
|
}, []);
|
|
136
136
|
|
|
137
137
|
useEffect(() => {
|
|
138
|
+
// Close on KEYUP + capture + stopImmediatePropagation — the same event
|
|
139
|
+
// RN-web Modal closes on, run first and stopped — so an alert opened over
|
|
140
|
+
// a drawer/dialog closes only the alert, not the modal behind it. Closing
|
|
141
|
+
// on keydown would unmount the alert before the keyup and let RN's keyup
|
|
142
|
+
// close the modal underneath too. (Matches the Popover's ESC handling.)
|
|
138
143
|
const handleEscape = (e: KeyboardEvent) => {
|
|
139
144
|
if (e.key === "Escape" && alertOptions.cancelable !== false) {
|
|
145
|
+
e.stopImmediatePropagation();
|
|
140
146
|
handleDismiss();
|
|
141
147
|
}
|
|
142
148
|
};
|
|
143
149
|
|
|
144
|
-
document.addEventListener("
|
|
145
|
-
return () => document.removeEventListener("
|
|
150
|
+
document.addEventListener("keyup", handleEscape, true);
|
|
151
|
+
return () => document.removeEventListener("keyup", handleEscape, true);
|
|
146
152
|
}, [handleDismiss]);
|
|
147
153
|
|
|
148
154
|
if (!visible) {
|
package/src/empty_state.tsx
CHANGED
|
@@ -1,21 +1,36 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
1
2
|
import { View, StyleSheet } from "react-native";
|
|
2
3
|
import { Text } from "./text";
|
|
4
|
+
import { Icon, type IconName } from "./icon";
|
|
5
|
+
import { colors } from "./colors";
|
|
3
6
|
|
|
4
7
|
interface EmptyStateProps {
|
|
5
8
|
/** What's empty, in plain language ("Không có nhân viên nào khớp"). */
|
|
6
9
|
message: string;
|
|
7
10
|
/** What the user can do about it ("Thử từ khóa khác"). */
|
|
8
11
|
hint?: string;
|
|
12
|
+
/** Optional Lucide glyph centered above the message — a visual anchor so the
|
|
13
|
+
* empty region reads as a deliberate state, not two lone lines of muted text. */
|
|
14
|
+
icon?: IconName;
|
|
15
|
+
/** Optional call-to-action below the text (e.g. a Button) for an actionable
|
|
16
|
+
* empty state ("no records yet → create one"). */
|
|
17
|
+
action?: ReactNode;
|
|
9
18
|
}
|
|
10
19
|
|
|
11
20
|
/**
|
|
12
21
|
* Centered placeholder for an empty list/filter result. Generous vertical
|
|
13
22
|
* space on purpose — an empty region that collapses to nothing reads as a
|
|
14
|
-
* rendering bug, not a state.
|
|
23
|
+
* rendering bug, not a state. Pass `icon` for a visual anchor and `action`
|
|
24
|
+
* for a CTA when the empty state is actionable.
|
|
15
25
|
*/
|
|
16
26
|
export function EmptyState(props: EmptyStateProps) {
|
|
17
27
|
return (
|
|
18
28
|
<View style={styles.container}>
|
|
29
|
+
{props.icon ? (
|
|
30
|
+
<View style={styles.icon}>
|
|
31
|
+
<Icon name={props.icon} size={28} color={colors.zinc[400]} />
|
|
32
|
+
</View>
|
|
33
|
+
) : null}
|
|
19
34
|
<Text size="sm" color="muted">
|
|
20
35
|
{props.message}
|
|
21
36
|
</Text>
|
|
@@ -24,6 +39,7 @@ export function EmptyState(props: EmptyStateProps) {
|
|
|
24
39
|
{props.hint}
|
|
25
40
|
</Text>
|
|
26
41
|
) : null}
|
|
42
|
+
{props.action ? <View style={styles.action}>{props.action}</View> : null}
|
|
27
43
|
</View>
|
|
28
44
|
);
|
|
29
45
|
}
|
|
@@ -34,4 +50,10 @@ const styles = StyleSheet.create({
|
|
|
34
50
|
alignItems: "center",
|
|
35
51
|
gap: 4,
|
|
36
52
|
},
|
|
53
|
+
icon: {
|
|
54
|
+
marginBottom: 4,
|
|
55
|
+
},
|
|
56
|
+
action: {
|
|
57
|
+
marginTop: 8,
|
|
58
|
+
},
|
|
37
59
|
});
|
|
@@ -16,6 +16,8 @@ import { Icon } from "./icon";
|
|
|
16
16
|
import { colors } from "./colors";
|
|
17
17
|
import type { DisplayFile } from "./file_thumbnail";
|
|
18
18
|
import { FilePreview } from "./file_preview";
|
|
19
|
+
import { isImageMimeType } from "./mime";
|
|
20
|
+
import { useImageRotation, type ImageRotation } from "./use_image_rotation";
|
|
19
21
|
import type { PreviewLabels } from "./file_preview_types";
|
|
20
22
|
|
|
21
23
|
export interface FileGalleryModalProps {
|
|
@@ -32,6 +34,14 @@ export interface FileGalleryModalProps {
|
|
|
32
34
|
labels?: Partial<PreviewLabels>;
|
|
33
35
|
/** Reported when a file fails to render (host wires to its logger). */
|
|
34
36
|
onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Optional shared rotation state (from `useImageRotation`) — pass it to keep an
|
|
39
|
+
* inline gallery and this modal in sync. Omitted ⇒ the modal keeps its own
|
|
40
|
+
* in-memory rotation. View-only; image files only.
|
|
41
|
+
*/
|
|
42
|
+
rotation?: ImageRotation;
|
|
43
|
+
/** Show 90° rotate controls for image files. Default true. */
|
|
44
|
+
rotatable?: boolean;
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
/**
|
|
@@ -39,9 +49,12 @@ export interface FileGalleryModalProps {
|
|
|
39
49
|
* via FilePreview; unknown types show a download placeholder.
|
|
40
50
|
*/
|
|
41
51
|
export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
42
|
-
const { files, activeIndex, onIndexChange, captionHint, labels, onError } = props;
|
|
52
|
+
const { files, activeIndex, onIndexChange, captionHint, labels, onError, rotation, rotatable = true } = props;
|
|
43
53
|
const visible = activeIndex !== null;
|
|
44
54
|
const overlayRef = useRef<View | null>(null);
|
|
55
|
+
// Own the rotation when the host doesn't share one (standalone consumers).
|
|
56
|
+
const internalRotation = useImageRotation();
|
|
57
|
+
const rot = rotation ?? internalRotation;
|
|
45
58
|
|
|
46
59
|
const close = useCallback(() => onIndexChange(null), [onIndexChange]);
|
|
47
60
|
const next = useCallback(() => {
|
|
@@ -53,22 +66,35 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
|
53
66
|
onIndexChange(Math.max(activeIndex - 1, 0));
|
|
54
67
|
}, [activeIndex, onIndexChange]);
|
|
55
68
|
|
|
56
|
-
//
|
|
69
|
+
// Arrow nav (web only — RN native handles via Modal accessibility). Capture +
|
|
70
|
+
// stopImmediatePropagation so ←/→ drive THIS gallery and never also reach a
|
|
71
|
+
// record-nav arrow handler on a drawer behind it.
|
|
72
|
+
//
|
|
73
|
+
// Escape is deliberately NOT handled here — it closes via the Modal's
|
|
74
|
+
// `onRequestClose`, which RN-web fires on KEYUP and scopes to the topmost
|
|
75
|
+
// modal (its `activeModalStack`). Closing here on KEYDOWN would unmount this
|
|
76
|
+
// modal first, so by the keyup RN's active modal is the drawer underneath and
|
|
77
|
+
// it closes that too — the "ESC closes the preview AND the record" bug.
|
|
57
78
|
useEffect(() => {
|
|
58
79
|
if (!visible || typeof window === "undefined") return;
|
|
59
80
|
function onKey(e: KeyboardEvent) {
|
|
60
|
-
if (e.key === "
|
|
61
|
-
|
|
62
|
-
|
|
81
|
+
if (e.key === "ArrowRight") {
|
|
82
|
+
e.stopImmediatePropagation();
|
|
83
|
+
next();
|
|
84
|
+
} else if (e.key === "ArrowLeft") {
|
|
85
|
+
e.stopImmediatePropagation();
|
|
86
|
+
prev();
|
|
87
|
+
}
|
|
63
88
|
}
|
|
64
|
-
window.addEventListener("keydown", onKey);
|
|
65
|
-
return () => window.removeEventListener("keydown", onKey);
|
|
66
|
-
}, [visible,
|
|
89
|
+
window.addEventListener("keydown", onKey, true);
|
|
90
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
91
|
+
}, [visible, next, prev]);
|
|
67
92
|
|
|
68
93
|
if (!visible || activeIndex === null) return null;
|
|
69
94
|
const file = files[activeIndex];
|
|
70
95
|
if (!file) return null;
|
|
71
96
|
|
|
97
|
+
const isImage = isImageMimeType(file.mimeType);
|
|
72
98
|
const total = files.length;
|
|
73
99
|
const caption = captionHint
|
|
74
100
|
? `${file.filename} · ${activeIndex + 1} / ${total} · ${captionHint}`
|
|
@@ -85,9 +111,30 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
|
85
111
|
/>
|
|
86
112
|
|
|
87
113
|
<View style={styles.previewPanel}>
|
|
88
|
-
<FilePreview file={file} labels={labels} onError={onError} />
|
|
114
|
+
<FilePreview file={file} labels={labels} onError={onError} rotation={rot.rotationFor(file.id)} />
|
|
89
115
|
</View>
|
|
90
116
|
|
|
117
|
+
{isImage && rotatable ? (
|
|
118
|
+
<View style={styles.controls}>
|
|
119
|
+
<Pressable
|
|
120
|
+
onPress={() => rot.rotate(file.id, -1)}
|
|
121
|
+
accessibilityRole="button"
|
|
122
|
+
accessibilityLabel="Rotate left"
|
|
123
|
+
style={styles.controlButton}
|
|
124
|
+
>
|
|
125
|
+
<Icon name="rotate-ccw" size={20} color={colors.white} />
|
|
126
|
+
</Pressable>
|
|
127
|
+
<Pressable
|
|
128
|
+
onPress={() => rot.rotate(file.id, 1)}
|
|
129
|
+
accessibilityRole="button"
|
|
130
|
+
accessibilityLabel="Rotate right"
|
|
131
|
+
style={styles.controlButton}
|
|
132
|
+
>
|
|
133
|
+
<Icon name="rotate-cw" size={20} color={colors.white} />
|
|
134
|
+
</Pressable>
|
|
135
|
+
</View>
|
|
136
|
+
) : null}
|
|
137
|
+
|
|
91
138
|
{activeIndex > 0 ? (
|
|
92
139
|
<Pressable
|
|
93
140
|
onPress={prev}
|
|
@@ -151,6 +198,21 @@ const styles = StyleSheet.create({
|
|
|
151
198
|
navRight: {
|
|
152
199
|
right: 16,
|
|
153
200
|
},
|
|
201
|
+
controls: {
|
|
202
|
+
position: "absolute",
|
|
203
|
+
top: 16,
|
|
204
|
+
right: 16,
|
|
205
|
+
flexDirection: "row",
|
|
206
|
+
gap: 8,
|
|
207
|
+
},
|
|
208
|
+
controlButton: {
|
|
209
|
+
width: 44,
|
|
210
|
+
height: 44,
|
|
211
|
+
borderRadius: 22,
|
|
212
|
+
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
|
213
|
+
justifyContent: "center",
|
|
214
|
+
alignItems: "center",
|
|
215
|
+
},
|
|
154
216
|
captionWrap: {
|
|
155
217
|
position: "absolute",
|
|
156
218
|
bottom: 24,
|
package/src/file_preview.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { View,
|
|
1
|
+
import { View, StyleSheet } from "react-native";
|
|
2
2
|
import { Text } from "./text";
|
|
3
3
|
import { DocumentCard } from "./file_thumbnail";
|
|
4
4
|
import { isImageMimeType } from "./mime";
|
|
5
|
+
import { RotatableImage } from "./rotatable_image";
|
|
5
6
|
import { type FilePreviewProps, resolveLabels } from "./file_preview_types";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -9,17 +10,10 @@ import { type FilePreviewProps, resolveLabels } from "./file_preview_types";
|
|
|
9
10
|
* document placeholder. Rich document rendering (docx/xlsx/pdf) is web-only —
|
|
10
11
|
* the `.web.tsx` variant carries it; native consumers download instead.
|
|
11
12
|
*/
|
|
12
|
-
export function FilePreview({ file, labels }: FilePreviewProps) {
|
|
13
|
+
export function FilePreview({ file, labels, rotation }: FilePreviewProps) {
|
|
13
14
|
const l = resolveLabels(labels);
|
|
14
15
|
if (isImageMimeType(file.mimeType)) {
|
|
15
|
-
return
|
|
16
|
-
<Image
|
|
17
|
-
source={{ uri: file.url }}
|
|
18
|
-
resizeMode="contain"
|
|
19
|
-
style={styles.image}
|
|
20
|
-
accessibilityIgnoresInvertColors
|
|
21
|
-
/>
|
|
22
|
-
);
|
|
16
|
+
return <RotatableImage uri={file.url} alt={file.filename} rotation={rotation} />;
|
|
23
17
|
}
|
|
24
18
|
return (
|
|
25
19
|
<View style={styles.placeholder}>
|
|
@@ -32,7 +26,6 @@ export function FilePreview({ file, labels }: FilePreviewProps) {
|
|
|
32
26
|
}
|
|
33
27
|
|
|
34
28
|
const styles = StyleSheet.create({
|
|
35
|
-
image: { flex: 1, width: "100%" },
|
|
36
29
|
placeholder: { flex: 1, justifyContent: "center", alignItems: "center", gap: 16 },
|
|
37
30
|
placeholderText: { marginTop: 8 },
|
|
38
31
|
});
|
package/src/file_preview.web.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "./mime";
|
|
14
14
|
import { type FilePreviewProps, resolveLabels, type PreviewLabels } from "./file_preview_types";
|
|
15
15
|
import { SpreadsheetView } from "./spreadsheet_view";
|
|
16
|
+
import { RotatableImage } from "./rotatable_image";
|
|
16
17
|
import { downloadFileFromUrl } from "./download";
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -24,11 +25,18 @@ import { downloadFileFromUrl } from "./download";
|
|
|
24
25
|
* Pure: i18n via `labels` props, errors via `onError` — no lingui/analytics
|
|
25
26
|
* (purity test). Lifted from the frontend gallery so both share one renderer.
|
|
26
27
|
*/
|
|
27
|
-
export function FilePreview({ file, labels, onError }: FilePreviewProps) {
|
|
28
|
+
export function FilePreview({ file, labels, onError, rotation }: FilePreviewProps) {
|
|
28
29
|
const l = resolveLabels(labels);
|
|
29
30
|
|
|
30
31
|
if (isImageMimeType(file.mimeType)) {
|
|
31
|
-
return
|
|
32
|
+
return (
|
|
33
|
+
<RotatableImage
|
|
34
|
+
uri={file.url}
|
|
35
|
+
alt={file.filename}
|
|
36
|
+
rotation={rotation}
|
|
37
|
+
backgroundColor="rgba(10, 10, 10, 0.8)"
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
32
40
|
}
|
|
33
41
|
if (isPdfMimeType(file.mimeType)) {
|
|
34
42
|
return <iframe src={file.url} title={file.filename} style={fullFrameStyle} />;
|
|
@@ -156,12 +164,6 @@ const styles = StyleSheet.create({
|
|
|
156
164
|
placeholderText: { marginTop: 8 },
|
|
157
165
|
});
|
|
158
166
|
|
|
159
|
-
const imageStyle: React.CSSProperties = {
|
|
160
|
-
width: "100%",
|
|
161
|
-
height: "100%",
|
|
162
|
-
objectFit: "contain",
|
|
163
|
-
backgroundColor: "rgba(10, 10, 10, 0.8)",
|
|
164
|
-
};
|
|
165
167
|
const fullFrameStyle: React.CSSProperties = { width: "100%", height: "100%", border: "none" };
|
|
166
168
|
const mediaElementStyle: React.CSSProperties = {
|
|
167
169
|
width: "100%",
|
|
@@ -28,6 +28,8 @@ export interface FilePreviewProps {
|
|
|
28
28
|
* package stays analytics-free), so the host wires this to its logger.
|
|
29
29
|
*/
|
|
30
30
|
onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
|
|
31
|
+
/** View rotation in degrees (0/90/180/270) — applied to image previews only. */
|
|
32
|
+
rotation?: number;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export function resolveLabels(labels: Partial<PreviewLabels> | undefined): PreviewLabels {
|
package/src/icon.tsx
CHANGED
|
@@ -153,6 +153,7 @@ import RectangleEllipsis from "lucide-react-native/dist/esm/icons/rectangle-elli
|
|
|
153
153
|
import Redo from "lucide-react-native/dist/esm/icons/redo";
|
|
154
154
|
import RefreshCw from "lucide-react-native/dist/esm/icons/refresh-cw";
|
|
155
155
|
import Repeat from "lucide-react-native/dist/esm/icons/repeat";
|
|
156
|
+
import RotateCcw from "lucide-react-native/dist/esm/icons/rotate-ccw";
|
|
156
157
|
import RotateCw from "lucide-react-native/dist/esm/icons/rotate-cw";
|
|
157
158
|
import Scan from "lucide-react-native/dist/esm/icons/scan";
|
|
158
159
|
import Search from "lucide-react-native/dist/esm/icons/search";
|
|
@@ -344,6 +345,7 @@ const iconComponents = {
|
|
|
344
345
|
redo: Redo,
|
|
345
346
|
"refresh-cw": RefreshCw,
|
|
346
347
|
repeat: Repeat,
|
|
348
|
+
"rotate-ccw": RotateCcw,
|
|
347
349
|
"rotate-cw": RotateCw,
|
|
348
350
|
scan: Scan,
|
|
349
351
|
search: Search,
|
package/src/image_gallery.tsx
CHANGED
|
@@ -8,15 +8,27 @@
|
|
|
8
8
|
// relying on percentage/flex-basis resolution (which collapses inside an
|
|
9
9
|
// auto-width parent).
|
|
10
10
|
import { useState } from "react";
|
|
11
|
-
import { View,
|
|
11
|
+
import { View, Pressable, type ViewStyle } from "react-native";
|
|
12
12
|
import { Text } from "./text";
|
|
13
|
+
import { Icon } from "./icon";
|
|
13
14
|
import { colors } from "./colors";
|
|
14
15
|
import { ActivityIndicator } from "./activity_indicator";
|
|
15
16
|
import { FileThumbnail, type DisplayFile } from "./file_thumbnail";
|
|
16
17
|
import { FileGalleryModal } from "./file_gallery_modal";
|
|
18
|
+
import { RotatableImage } from "./rotatable_image";
|
|
19
|
+
import { useImageRotation } from "./use_image_rotation";
|
|
17
20
|
|
|
18
21
|
const GAP = 12;
|
|
19
22
|
|
|
23
|
+
const rotateButtonStyle: ViewStyle = {
|
|
24
|
+
width: 32,
|
|
25
|
+
height: 32,
|
|
26
|
+
borderRadius: 16,
|
|
27
|
+
backgroundColor: "rgba(0, 0, 0, 0.45)",
|
|
28
|
+
alignItems: "center",
|
|
29
|
+
justifyContent: "center",
|
|
30
|
+
};
|
|
31
|
+
|
|
20
32
|
export interface ImageGalleryProps {
|
|
21
33
|
images: DisplayFile[];
|
|
22
34
|
loading?: boolean;
|
|
@@ -34,6 +46,8 @@ export interface ImageGalleryProps {
|
|
|
34
46
|
mainAspectRatio?: number;
|
|
35
47
|
/** Main image's fraction of the width in "right" mode (0–1). Default 0.6. */
|
|
36
48
|
mainFraction?: number;
|
|
49
|
+
/** Show 90° rotate controls on images — inline and in the zoom modal. Default true. */
|
|
50
|
+
rotatable?: boolean;
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
export function ImageGallery({
|
|
@@ -45,10 +59,13 @@ export function ImageGallery({
|
|
|
45
59
|
thumbnailPosition = "bottom",
|
|
46
60
|
mainAspectRatio = 4 / 3,
|
|
47
61
|
mainFraction = 0.6,
|
|
62
|
+
rotatable = true,
|
|
48
63
|
}: ImageGalleryProps) {
|
|
49
64
|
const [selected, setSelected] = useState(0);
|
|
50
65
|
const [zoomIdx, setZoomIdx] = useState<number | null>(null);
|
|
51
66
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
67
|
+
// Per-image rotation, shared with the zoom modal so a turn survives opening it.
|
|
68
|
+
const rotation = useImageRotation();
|
|
52
69
|
|
|
53
70
|
if (loading) return <ActivityIndicator />;
|
|
54
71
|
if (images.length === 0) return <Text size="sm" color="muted">{emptyText}</Text>;
|
|
@@ -59,23 +76,47 @@ export function ImageGallery({
|
|
|
59
76
|
const width = maxWidth ? Math.min(containerWidth, maxWidth) : containerWidth;
|
|
60
77
|
|
|
61
78
|
const mainImage = (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
// The rotate controls sit OUTSIDE the zoom Pressable (an absolute sibling on
|
|
80
|
+
// top) so pressing them turns the image instead of opening the modal.
|
|
81
|
+
<View style={{ width: "100%" }}>
|
|
82
|
+
<Pressable
|
|
83
|
+
onPress={() => setZoomIdx(idx)}
|
|
84
|
+
accessibilityRole="button"
|
|
85
|
+
accessibilityLabel="Zoom image"
|
|
86
|
+
style={{
|
|
87
|
+
width: "100%",
|
|
88
|
+
aspectRatio: mainAspectRatio,
|
|
89
|
+
borderRadius: 8,
|
|
90
|
+
borderWidth: 1,
|
|
91
|
+
borderColor: colors.border,
|
|
92
|
+
backgroundColor: colors.zinc["100"],
|
|
93
|
+
overflow: "hidden",
|
|
94
|
+
cursor: "pointer",
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<RotatableImage uri={main.url} alt={main.filename} rotation={rotation.rotationFor(main.id)} />
|
|
98
|
+
</Pressable>
|
|
99
|
+
{rotatable ? (
|
|
100
|
+
<View style={{ position: "absolute", top: 8, right: 8, flexDirection: "row", gap: 6 }}>
|
|
101
|
+
<Pressable
|
|
102
|
+
onPress={() => rotation.rotate(main.id, -1)}
|
|
103
|
+
accessibilityRole="button"
|
|
104
|
+
accessibilityLabel="Rotate left"
|
|
105
|
+
style={rotateButtonStyle}
|
|
106
|
+
>
|
|
107
|
+
<Icon name="rotate-ccw" size={16} color={colors.white} />
|
|
108
|
+
</Pressable>
|
|
109
|
+
<Pressable
|
|
110
|
+
onPress={() => rotation.rotate(main.id, 1)}
|
|
111
|
+
accessibilityRole="button"
|
|
112
|
+
accessibilityLabel="Rotate right"
|
|
113
|
+
style={rotateButtonStyle}
|
|
114
|
+
>
|
|
115
|
+
<Icon name="rotate-cw" size={16} color={colors.white} />
|
|
116
|
+
</Pressable>
|
|
117
|
+
</View>
|
|
118
|
+
) : null}
|
|
119
|
+
</View>
|
|
79
120
|
);
|
|
80
121
|
|
|
81
122
|
const thumbnails = images.length > 1 ? (
|
|
@@ -117,6 +158,8 @@ export function ImageGallery({
|
|
|
117
158
|
activeIndex={zoomIdx}
|
|
118
159
|
onIndexChange={setZoomIdx}
|
|
119
160
|
captionHint={captionHint}
|
|
161
|
+
rotation={rotation}
|
|
162
|
+
rotatable={rotatable}
|
|
120
163
|
/>
|
|
121
164
|
</View>
|
|
122
165
|
);
|
package/src/pagination.tsx
CHANGED
|
@@ -3,6 +3,34 @@ import { View, StyleSheet } from "react-native";
|
|
|
3
3
|
import { IconButton } from "./icon_button";
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Localizable strings for `Pagination`. Every field is optional; an omitted
|
|
8
|
+
* field keeps the English default, so an app that needs another language passes
|
|
9
|
+
* only what it overrides (mirrors `DateRangeFilterField`'s `labels`). The
|
|
10
|
+
* formatters receive 1-indexed display numbers and the raw total, so a caller
|
|
11
|
+
* controls its own locale (e.g. `total.toLocaleString("vi-VN")`).
|
|
12
|
+
*/
|
|
13
|
+
export interface PaginationLabels {
|
|
14
|
+
/** Range summary with a known total. Default `${start}–${end} of ${total}`. */
|
|
15
|
+
rangeWithTotal?: (start: number, end: number, total: number) => string;
|
|
16
|
+
/** Range summary without a total. Default `${start}–${end}`. */
|
|
17
|
+
range?: (start: number, end: number) => string;
|
|
18
|
+
/** Page indicator with a known total. Default `Page ${page} of ${pageCount}`. */
|
|
19
|
+
pageWithTotal?: (page: number, pageCount: number) => string;
|
|
20
|
+
/** Page indicator without a total. Default `Page ${page}`. */
|
|
21
|
+
page?: (page: number) => string;
|
|
22
|
+
/** Previous-page button tooltip. Default "Previous". */
|
|
23
|
+
previous?: string;
|
|
24
|
+
/** Next-page button tooltip. Default "Next". */
|
|
25
|
+
next?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const defaultRangeWithTotal = (start: number, end: number, total: number): string =>
|
|
29
|
+
`${start}–${end} of ${total.toLocaleString()}`;
|
|
30
|
+
const defaultRange = (start: number, end: number): string => `${start}–${end}`;
|
|
31
|
+
const defaultPageWithTotal = (page: number, pageCount: number): string => `Page ${page} of ${pageCount}`;
|
|
32
|
+
const defaultPage = (page: number): string => `Page ${page}`;
|
|
33
|
+
|
|
6
34
|
export interface PaginationProps {
|
|
7
35
|
/** 0-indexed. */
|
|
8
36
|
page: number;
|
|
@@ -18,6 +46,8 @@ export interface PaginationProps {
|
|
|
18
46
|
total?: number;
|
|
19
47
|
loading?: boolean;
|
|
20
48
|
onPageChange: (page: number) => void;
|
|
49
|
+
/** Override the English strings for localization. Omit to keep defaults. */
|
|
50
|
+
labels?: PaginationLabels;
|
|
21
51
|
}
|
|
22
52
|
|
|
23
53
|
/**
|
|
@@ -28,21 +58,21 @@ export interface PaginationProps {
|
|
|
28
58
|
* doubling up a border.
|
|
29
59
|
*/
|
|
30
60
|
export function Pagination(props: PaginationProps): React.ReactNode {
|
|
31
|
-
const { page, pageSize, rowCount, hasMore, total, loading, onPageChange } = props;
|
|
61
|
+
const { page, pageSize, rowCount, hasMore, total, loading, onPageChange, labels } = props;
|
|
32
62
|
const start = page * pageSize + 1;
|
|
33
63
|
const end = page * pageSize + rowCount;
|
|
34
64
|
const showRange = !loading && rowCount > 0;
|
|
35
65
|
|
|
36
66
|
const summary = showRange
|
|
37
67
|
? total !== undefined
|
|
38
|
-
?
|
|
39
|
-
:
|
|
68
|
+
? (labels?.rangeWithTotal ?? defaultRangeWithTotal)(start, end, total)
|
|
69
|
+
: (labels?.range ?? defaultRange)(start, end)
|
|
40
70
|
: "";
|
|
41
71
|
|
|
42
72
|
const pageLabel =
|
|
43
73
|
total !== undefined
|
|
44
|
-
?
|
|
45
|
-
:
|
|
74
|
+
? (labels?.pageWithTotal ?? defaultPageWithTotal)(page + 1, Math.max(1, Math.ceil(total / pageSize)))
|
|
75
|
+
: (labels?.page ?? defaultPage)(page + 1);
|
|
46
76
|
|
|
47
77
|
return (
|
|
48
78
|
<View style={styles.container}>
|
|
@@ -60,14 +90,14 @@ export function Pagination(props: PaginationProps): React.ReactNode {
|
|
|
60
90
|
color="secondary"
|
|
61
91
|
onPress={() => onPageChange(Math.max(0, page - 1))}
|
|
62
92
|
disabled={page === 0 || !!loading}
|
|
63
|
-
tooltip="Previous"
|
|
93
|
+
tooltip={labels?.previous ?? "Previous"}
|
|
64
94
|
/>
|
|
65
95
|
<IconButton
|
|
66
96
|
icon="chevron-right"
|
|
67
97
|
color="secondary"
|
|
68
98
|
onPress={() => onPageChange(page + 1)}
|
|
69
99
|
disabled={!hasMore || !!loading}
|
|
70
|
-
tooltip="Next"
|
|
100
|
+
tooltip={labels?.next ?? "Next"}
|
|
71
101
|
/>
|
|
72
102
|
</View>
|
|
73
103
|
</View>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { View, Image, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
|
|
4
|
+
export interface RotatableImageProps {
|
|
5
|
+
uri: string;
|
|
6
|
+
/** Accessible name for the image. */
|
|
7
|
+
alt?: string;
|
|
8
|
+
/** Degrees clockwise — normalized to 0/90/180/270. */
|
|
9
|
+
rotation?: number;
|
|
10
|
+
/** Fill behind the contained image. */
|
|
11
|
+
backgroundColor?: string;
|
|
12
|
+
style?: StyleProp<ViewStyle>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* An image that rotates in 90° steps and REFITS: at a quarter turn the image box
|
|
17
|
+
* is laid out in the container's SWAPPED dimensions, then rotated into place — so
|
|
18
|
+
* a rotated landscape photo fills the frame instead of shrinking into a corner.
|
|
19
|
+
* `resizeMode="contain"` preserves aspect ratio; the container measures itself
|
|
20
|
+
* via `onLayout`. Works on web (react-native-web) and native.
|
|
21
|
+
*/
|
|
22
|
+
export function RotatableImage({ uri, alt, rotation = 0, backgroundColor, style }: RotatableImageProps) {
|
|
23
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
24
|
+
const deg = (((rotation % 360) + 360) % 360);
|
|
25
|
+
const quarter = deg === 90 || deg === 270;
|
|
26
|
+
const boxWidth = quarter ? size.height : size.width;
|
|
27
|
+
const boxHeight = quarter ? size.width : size.height;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<View
|
|
31
|
+
onLayout={(e) =>
|
|
32
|
+
setSize({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height })
|
|
33
|
+
}
|
|
34
|
+
style={[styles.container, backgroundColor ? { backgroundColor } : null, style]}
|
|
35
|
+
>
|
|
36
|
+
{size.width > 0 && size.height > 0 ? (
|
|
37
|
+
<Image
|
|
38
|
+
source={{ uri }}
|
|
39
|
+
resizeMode="contain"
|
|
40
|
+
accessibilityLabel={alt}
|
|
41
|
+
style={{ width: boxWidth, height: boxHeight, transform: [{ rotate: `${deg}deg` }] }}
|
|
42
|
+
/>
|
|
43
|
+
) : null}
|
|
44
|
+
</View>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const styles = StyleSheet.create({
|
|
49
|
+
container: {
|
|
50
|
+
width: "100%",
|
|
51
|
+
height: "100%",
|
|
52
|
+
alignItems: "center",
|
|
53
|
+
justifyContent: "center",
|
|
54
|
+
overflow: "hidden",
|
|
55
|
+
},
|
|
56
|
+
});
|
package/src/sort_header.tsx
CHANGED
|
@@ -39,6 +39,20 @@ export function sortBy<T>(
|
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Localizable a11y fragments for `SortHeader`. The visible header is the app's
|
|
44
|
+
* own `label`; only the screen-reader announcement is built here, so these
|
|
45
|
+
* cover that string. Omit any field to keep the English default.
|
|
46
|
+
*/
|
|
47
|
+
export interface SortHeaderLabels {
|
|
48
|
+
/** a11y prefix around the column label. Default `(label) => `Sort by ${label}``. */
|
|
49
|
+
sortBy?: (label: string) => string;
|
|
50
|
+
/** Appended when sorted ascending. Default ", ascending". */
|
|
51
|
+
ascending?: string;
|
|
52
|
+
/** Appended when sorted descending. Default ", descending". */
|
|
53
|
+
descending?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
export interface SortHeaderProps {
|
|
43
57
|
label: string;
|
|
44
58
|
sortKey: string;
|
|
@@ -47,6 +61,8 @@ export interface SortHeaderProps {
|
|
|
47
61
|
/** Right-align for numeric columns — the arrow then sits LEFT of the label. */
|
|
48
62
|
align?: "left" | "right";
|
|
49
63
|
style?: ViewStyle;
|
|
64
|
+
/** Override the English a11y strings for localization. Omit to keep defaults. */
|
|
65
|
+
labels?: SortHeaderLabels;
|
|
50
66
|
}
|
|
51
67
|
|
|
52
68
|
/**
|
|
@@ -57,15 +73,20 @@ export interface SortHeaderProps {
|
|
|
57
73
|
* flush with the column content beneath it.
|
|
58
74
|
*/
|
|
59
75
|
export function SortHeader(props: SortHeaderProps) {
|
|
60
|
-
const { label, sortKey, sort, onSort, align = "left", style } = props;
|
|
76
|
+
const { label, sortKey, sort, onSort, align = "left", style, labels } = props;
|
|
61
77
|
const active = sort?.key === sortKey;
|
|
62
78
|
const arrow = active ? (sort.dir === "asc" ? "chevron-up" : "chevron-down") : undefined;
|
|
63
|
-
const dirText = active
|
|
79
|
+
const dirText = active
|
|
80
|
+
? sort.dir === "asc"
|
|
81
|
+
? (labels?.ascending ?? ", ascending")
|
|
82
|
+
: (labels?.descending ?? ", descending")
|
|
83
|
+
: "";
|
|
84
|
+
const sortByLabel = (labels?.sortBy ?? ((l: string) => `Sort by ${l}`))(label);
|
|
64
85
|
|
|
65
86
|
return (
|
|
66
87
|
<PressableHighlight
|
|
67
88
|
accessibilityRole="button"
|
|
68
|
-
accessibilityLabel={
|
|
89
|
+
accessibilityLabel={`${sortByLabel}${dirText}`}
|
|
69
90
|
onPress={() => onSort(sortKey)}
|
|
70
91
|
style={[styles.header, align === "right" ? styles.right : null, style]}
|
|
71
92
|
>
|
package/src/table.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import { StyleSheet, View, Pressable, type ViewStyle } from "react-native";
|
|
|
11
11
|
import { Text } from "./text";
|
|
12
12
|
import { Divider } from "./divider";
|
|
13
13
|
import { PressableRow } from "./pressable_row";
|
|
14
|
-
import { SortHeader, type SortState } from "./sort_header";
|
|
14
|
+
import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* One column of a register — its width/flex/align/label/sortability defined ONCE,
|
|
@@ -50,6 +50,8 @@ export interface TableProps {
|
|
|
50
50
|
sort?: SortState | null;
|
|
51
51
|
/** Cycles the sort (none→asc→desc→none) — pair with `cycleSort` in the parent. */
|
|
52
52
|
onSort?: (key: string) => void;
|
|
53
|
+
/** Localized a11y strings for the sortable headers. Omit to keep English. */
|
|
54
|
+
sortLabels?: SortHeaderLabels;
|
|
53
55
|
/** Reserve a leading gutter (px) for rows that render a `leading` slot (a checkbox). */
|
|
54
56
|
leading?: number;
|
|
55
57
|
/** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
|
|
@@ -67,7 +69,7 @@ export interface TableProps {
|
|
|
67
69
|
* non-columnar list (entity piles, card stacks) use `PressableRow` directly.
|
|
68
70
|
*/
|
|
69
71
|
export function Table(props: TableProps) {
|
|
70
|
-
const { columns, sort, onSort, leading = 0, trailing = 0, children } = props;
|
|
72
|
+
const { columns, sort, onSort, sortLabels, leading = 0, trailing = 0, children } = props;
|
|
71
73
|
const rows = Children.toArray(children).filter(isValidElement);
|
|
72
74
|
|
|
73
75
|
return (
|
|
@@ -78,7 +80,7 @@ export function Table(props: TableProps) {
|
|
|
78
80
|
<View key={col.key} style={colStyle(col)}>
|
|
79
81
|
{col.label ? (
|
|
80
82
|
col.sortable && onSort ? (
|
|
81
|
-
<SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} />
|
|
83
|
+
<SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} labels={sortLabels} />
|
|
82
84
|
) : (
|
|
83
85
|
<Text size="xs" color="muted" transform="uppercase" numberOfLines={1}>
|
|
84
86
|
{col.label}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-file view rotation for image viewers — degrees (0/90/180/270) keyed by
|
|
5
|
+
* file id, held in memory. Shared between an inline gallery and its fullscreen
|
|
6
|
+
* modal so a rotation survives prev/next navigation and open/close while the
|
|
7
|
+
* gallery is mounted. View-only: it is a render transform, never a mutation of
|
|
8
|
+
* the stored file.
|
|
9
|
+
*/
|
|
10
|
+
export interface ImageRotation {
|
|
11
|
+
rotations: Record<string, number>;
|
|
12
|
+
rotationFor: (id: string) => number;
|
|
13
|
+
/** Advance a quarter-turn; `step` 1 = clockwise, -1 = counter-clockwise. */
|
|
14
|
+
rotate: (id: string, step?: 1 | -1) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useImageRotation(): ImageRotation {
|
|
18
|
+
const [rotations, setRotations] = useState<Record<string, number>>({});
|
|
19
|
+
|
|
20
|
+
const rotate = useCallback((id: string, step: 1 | -1 = 1) => {
|
|
21
|
+
setRotations((prev) => {
|
|
22
|
+
const next = ((((prev[id] ?? 0) + step * 90) % 360) + 360) % 360;
|
|
23
|
+
return { ...prev, [id]: next };
|
|
24
|
+
});
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
const rotationFor = useCallback((id: string) => rotations[id] ?? 0, [rotations]);
|
|
28
|
+
|
|
29
|
+
return useMemo(() => ({ rotations, rotationFor, rotate }), [rotations, rotationFor, rotate]);
|
|
30
|
+
}
|