@lotics/ui 3.3.0 → 3.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/package.json +4 -1
- package/src/alert.tsx +8 -2
- package/src/callout.tsx +75 -0
- 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/filter_pill.tsx +18 -5
- package/src/icon.tsx +4 -0
- package/src/image_gallery.tsx +61 -18
- package/src/rotatable_image.tsx +56 -0
- 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.5.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",
|
|
@@ -79,6 +81,7 @@
|
|
|
79
81
|
"./action_menu": "./src/action_menu.tsx",
|
|
80
82
|
"./card_select_item": "./src/card_select_item.tsx",
|
|
81
83
|
"./badge": "./src/badge.tsx",
|
|
84
|
+
"./callout": "./src/callout.tsx",
|
|
82
85
|
"./divider": "./src/divider.tsx",
|
|
83
86
|
"./spacer": "./src/spacer.tsx",
|
|
84
87
|
"./index.css": "./src/index.css",
|
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/callout.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { View, StyleSheet } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { Icon, type IconName } from "./icon";
|
|
4
|
+
import { colors } from "./colors";
|
|
5
|
+
|
|
6
|
+
export type CalloutTone = "info" | "success" | "warning" | "error" | "neutral";
|
|
7
|
+
|
|
8
|
+
interface CalloutProps {
|
|
9
|
+
/** Sets the accent (icon + tint + border) and the default icon. Default "info". */
|
|
10
|
+
tone?: CalloutTone;
|
|
11
|
+
/** Optional bold heading above the message. */
|
|
12
|
+
title?: string;
|
|
13
|
+
/** The message body — kept in the default text color for legibility on the tint. */
|
|
14
|
+
message: string;
|
|
15
|
+
/** Override the per-tone default icon (any kit icon), or `null` to drop it. */
|
|
16
|
+
icon?: IconName | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ToneStyle {
|
|
20
|
+
bg: string;
|
|
21
|
+
border: string;
|
|
22
|
+
icon: string;
|
|
23
|
+
glyph: IconName;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TONES: Record<CalloutTone, ToneStyle> = {
|
|
27
|
+
info: { bg: colors.blue[50], border: colors.blue[200], icon: colors.blue[600], glyph: "info" },
|
|
28
|
+
success: { bg: colors.emerald[50], border: colors.emerald[200], icon: colors.emerald[600], glyph: "circle-check" },
|
|
29
|
+
warning: { bg: colors.amber[50], border: colors.amber[200], icon: colors.amber[600], glyph: "triangle-alert" },
|
|
30
|
+
error: { bg: colors.red[50], border: colors.red[200], icon: colors.red[600], glyph: "circle-alert" },
|
|
31
|
+
neutral: { bg: colors.zinc[50], border: colors.zinc[200], icon: colors.zinc[500], glyph: "info" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An inline callout — a tinted, bordered box carrying a short status message
|
|
36
|
+
* (info / success / warning / error / neutral). Use it inline in a flow: form
|
|
37
|
+
* feedback, a heads-up, an actionable empty state. For a blocking, dismissible
|
|
38
|
+
* prompt use `Alert`; for a one-word status use `Badge`. The tone is carried by
|
|
39
|
+
* the icon + tint + border (never color alone — the icon and text stay legible),
|
|
40
|
+
* so it reads on a glance and meets contrast on the light tint.
|
|
41
|
+
*/
|
|
42
|
+
export function Callout({ tone = "info", title, message, icon }: CalloutProps) {
|
|
43
|
+
const t = TONES[tone];
|
|
44
|
+
const glyph = icon === null ? null : (icon ?? t.glyph);
|
|
45
|
+
return (
|
|
46
|
+
<View
|
|
47
|
+
accessibilityRole={tone === "error" || tone === "warning" ? "alert" : undefined}
|
|
48
|
+
style={[styles.container, { backgroundColor: t.bg, borderColor: t.border }]}
|
|
49
|
+
>
|
|
50
|
+
{glyph ? <Icon name={glyph} size={18} color={t.icon} /> : null}
|
|
51
|
+
<View style={styles.body}>
|
|
52
|
+
{title ? (
|
|
53
|
+
<Text size="sm" weight="semibold">
|
|
54
|
+
{title}
|
|
55
|
+
</Text>
|
|
56
|
+
) : null}
|
|
57
|
+
<Text size="sm">{message}</Text>
|
|
58
|
+
</View>
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type { CalloutProps };
|
|
64
|
+
|
|
65
|
+
const styles = StyleSheet.create({
|
|
66
|
+
container: {
|
|
67
|
+
flexDirection: "row",
|
|
68
|
+
alignItems: "flex-start",
|
|
69
|
+
gap: 10,
|
|
70
|
+
padding: 12,
|
|
71
|
+
borderRadius: 10,
|
|
72
|
+
borderWidth: 1,
|
|
73
|
+
},
|
|
74
|
+
body: { flex: 1, gap: 2, paddingTop: 1 },
|
|
75
|
+
});
|
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/filter_pill.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
1
|
+
import { type ReactNode, useState } from "react";
|
|
2
2
|
import { StyleSheet } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { Icon } from "./icon";
|
|
@@ -23,8 +23,11 @@ export interface FilterPillProps {
|
|
|
23
23
|
clearLabel?: string;
|
|
24
24
|
/** The editor revealed on press — a premium primitive (`RangeSlider`,
|
|
25
25
|
* `Counter`, `PickerMenu` multi) or any composed control. `FilterPill` owns
|
|
26
|
-
* the pill + popover chrome; the consumer owns the value and applies it.
|
|
27
|
-
|
|
26
|
+
* the pill + popover chrome; the consumer owns the value and applies it.
|
|
27
|
+
* Pass a render function to receive `close` — the one editor that closes on
|
|
28
|
+
* action is single-select (`<PickerMenu>` → close on pick); range/counter/
|
|
29
|
+
* multi stay open and dismiss on outside-press, so they pass a plain node. */
|
|
30
|
+
children: ReactNode | ((api: { close: () => void }) => ReactNode);
|
|
28
31
|
side?: PopoverSide;
|
|
29
32
|
align?: PopoverAlign;
|
|
30
33
|
/** Optional controlled popover state — for an editor that closes on Save. */
|
|
@@ -71,8 +74,18 @@ export function FilterPill(props: FilterPillProps) {
|
|
|
71
74
|
// custom footer — a valued, non-clearable pill ("Target: 20") keeps its chevron.
|
|
72
75
|
const showClear = active && !!onClear && !footer;
|
|
73
76
|
|
|
77
|
+
// Own the open state so the editor can close itself via the render-prop
|
|
78
|
+
// `close`, while still honoring a controlled `open`/`onOpenChange` from the
|
|
79
|
+
// parent (the Save-footer VALUE-pill case).
|
|
80
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
81
|
+
const isOpen = open ?? internalOpen;
|
|
82
|
+
const setOpen = (next: boolean) => {
|
|
83
|
+
if (open === undefined) setInternalOpen(next);
|
|
84
|
+
onOpenChange?.(next);
|
|
85
|
+
};
|
|
86
|
+
|
|
74
87
|
return (
|
|
75
|
-
<Popover side={side} align={align} open={
|
|
88
|
+
<Popover side={side} align={align} open={isOpen} onOpenChange={setOpen}>
|
|
76
89
|
<PopoverTrigger>
|
|
77
90
|
<PillButton onDismiss={showClear ? onClear : undefined} dismissTooltip={clearLabel}>
|
|
78
91
|
<Text size="sm" weight="medium" color={active ? "zinc-900" : "zinc-700"} numberOfLines={1}>
|
|
@@ -82,7 +95,7 @@ export function FilterPill(props: FilterPillProps) {
|
|
|
82
95
|
</PillButton>
|
|
83
96
|
</PopoverTrigger>
|
|
84
97
|
<PopoverContent style={styles.body} disableBodyScroll>
|
|
85
|
-
{children}
|
|
98
|
+
{typeof children === "function" ? children({ close: () => setOpen(false) }) : children}
|
|
86
99
|
{footer ? (
|
|
87
100
|
<PopoverFooter>{footer}</PopoverFooter>
|
|
88
101
|
) : showClear ? (
|
package/src/icon.tsx
CHANGED
|
@@ -51,6 +51,7 @@ import ChevronsDownUp from "lucide-react-native/dist/esm/icons/chevrons-down-up"
|
|
|
51
51
|
import ChevronsUpDown from "lucide-react-native/dist/esm/icons/chevrons-up-down";
|
|
52
52
|
import CircleAlert from "lucide-react-native/dist/esm/icons/circle-alert";
|
|
53
53
|
import CircleCheck from "lucide-react-native/dist/esm/icons/circle-check";
|
|
54
|
+
import TriangleAlert from "lucide-react-native/dist/esm/icons/triangle-alert";
|
|
54
55
|
import Clock from "lucide-react-native/dist/esm/icons/clock";
|
|
55
56
|
import Code from "lucide-react-native/dist/esm/icons/code";
|
|
56
57
|
import CodeXml from "lucide-react-native/dist/esm/icons/code-xml";
|
|
@@ -153,6 +154,7 @@ import RectangleEllipsis from "lucide-react-native/dist/esm/icons/rectangle-elli
|
|
|
153
154
|
import Redo from "lucide-react-native/dist/esm/icons/redo";
|
|
154
155
|
import RefreshCw from "lucide-react-native/dist/esm/icons/refresh-cw";
|
|
155
156
|
import Repeat from "lucide-react-native/dist/esm/icons/repeat";
|
|
157
|
+
import RotateCcw from "lucide-react-native/dist/esm/icons/rotate-ccw";
|
|
156
158
|
import RotateCw from "lucide-react-native/dist/esm/icons/rotate-cw";
|
|
157
159
|
import Scan from "lucide-react-native/dist/esm/icons/scan";
|
|
158
160
|
import Search from "lucide-react-native/dist/esm/icons/search";
|
|
@@ -241,6 +243,7 @@ const iconComponents = {
|
|
|
241
243
|
"chevrons-up-down": ChevronsUpDown,
|
|
242
244
|
"circle-alert": CircleAlert,
|
|
243
245
|
"circle-check": CircleCheck,
|
|
246
|
+
"triangle-alert": TriangleAlert,
|
|
244
247
|
clock: Clock,
|
|
245
248
|
code: Code,
|
|
246
249
|
"code-xml": CodeXml,
|
|
@@ -344,6 +347,7 @@ const iconComponents = {
|
|
|
344
347
|
redo: Redo,
|
|
345
348
|
"refresh-cw": RefreshCw,
|
|
346
349
|
repeat: Repeat,
|
|
350
|
+
"rotate-ccw": RotateCcw,
|
|
347
351
|
"rotate-cw": RotateCw,
|
|
348
352
|
scan: Scan,
|
|
349
353
|
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
|
);
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|