@lotics/ui 2.4.1 → 2.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 +27 -8
- package/src/accordion.tsx +146 -63
- package/src/action_menu.tsx +72 -0
- package/src/allocation_row.tsx +54 -0
- package/src/badge.tsx +40 -9
- package/src/breakdown.tsx +121 -0
- package/src/card.tsx +150 -0
- package/src/cell_select.tsx +3 -2
- package/src/chip_group.tsx +65 -0
- package/src/colors.ts +61 -0
- package/src/column_filter.tsx +9 -24
- package/src/completion_state.tsx +43 -0
- package/src/control_surface.ts +32 -0
- package/src/counter.tsx +58 -0
- package/src/date_range_filter_field.tsx +44 -12
- package/src/detail_row.tsx +45 -0
- package/src/dialog.tsx +0 -24
- package/src/download.ts +2 -1
- package/src/drawer.tsx +94 -2
- package/src/empty_state.tsx +37 -0
- package/src/file_badge.tsx +27 -4
- package/src/file_dropzone.tsx +188 -0
- package/src/file_picker.ts +45 -0
- package/src/filter_pill.tsx +106 -0
- package/src/floating_action_bar.tsx +57 -0
- package/src/fonts.css +10 -13
- package/src/format_money.ts +38 -0
- package/src/heatmap.tsx +153 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +16 -2
- package/src/index.css +4 -3
- package/src/info_popover.tsx +4 -6
- package/src/kpi_card.tsx +19 -6
- package/src/kpi_strip.tsx +89 -0
- package/src/line_chart.tsx +61 -34
- package/src/link_button.tsx +50 -0
- package/src/metric.tsx +21 -12
- package/src/pagination.tsx +5 -9
- package/src/peek.tsx +68 -0
- package/src/picker.tsx +13 -1
- package/src/picker_menu.tsx +8 -16
- package/src/pie_chart.tsx +29 -8
- package/src/pill_button.tsx +10 -8
- package/src/popover.tsx +14 -4
- package/src/pressable_highlight.tsx +10 -1
- package/src/pressable_row.tsx +91 -0
- package/src/progress_bar.tsx +47 -17
- package/src/radio_picker.tsx +20 -9
- package/src/range_slider.tsx +185 -0
- package/src/remainder_meter.tsx +48 -0
- package/src/ring_gauge.tsx +5 -5
- package/src/scan_field.tsx +58 -0
- package/src/search_input.tsx +12 -0
- package/src/sort_header.tsx +102 -0
- package/src/stacked_progress_bar.tsx +51 -16
- package/src/status_grid.tsx +187 -0
- package/src/step_list.tsx +128 -0
- package/src/step_progress.tsx +145 -0
- package/src/stepper.tsx +9 -4
- package/src/table.tsx +168 -112
- package/src/text.tsx +15 -0
- package/src/text_utils.ts +10 -0
- package/src/timeline.tsx +90 -57
- package/src/trend_footer.tsx +2 -2
- package/src/alert_row.tsx +0 -81
- package/src/table.web.tsx +0 -235
- package/src/table_picker.tsx +0 -305
- package/src/table_types.ts +0 -47
package/src/download.ts
CHANGED
|
@@ -22,7 +22,7 @@ export async function downloadFileFromUrl(
|
|
|
22
22
|
url: string,
|
|
23
23
|
filename: string,
|
|
24
24
|
opts?: { credentials?: RequestCredentials },
|
|
25
|
-
): Promise<
|
|
25
|
+
): Promise<{ bytes: number }> {
|
|
26
26
|
if (!url) throw new Error("downloadFileFromUrl: empty url");
|
|
27
27
|
|
|
28
28
|
const response = await fetch(url, {
|
|
@@ -45,4 +45,5 @@ export async function downloadFileFromUrl(
|
|
|
45
45
|
} finally {
|
|
46
46
|
URL.revokeObjectURL(blobUrl);
|
|
47
47
|
}
|
|
48
|
+
return { bytes: blob.size };
|
|
48
49
|
}
|
package/src/drawer.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { ReactNode } from "react";
|
|
1
|
+
import { ReactNode, useEffect } from "react";
|
|
2
2
|
import { Modal, Pressable, StyleSheet, View } from "react-native";
|
|
3
3
|
import { useScreenSize } from "@lotics/ui/use_screen_size";
|
|
4
4
|
import { PortalHost } from "@lotics/ui/portal";
|
|
5
5
|
import { colors } from "@lotics/ui/colors";
|
|
6
6
|
import { Button } from "@lotics/ui/button";
|
|
7
|
+
import { IconButton } from "@lotics/ui/icon_button";
|
|
7
8
|
import { Text } from "@lotics/ui/text";
|
|
8
9
|
import { useOverlayScope } from "@lotics/ui/overlay_scope";
|
|
9
10
|
|
|
@@ -14,6 +15,17 @@ export interface DrawerProps {
|
|
|
14
15
|
title?: ReactNode;
|
|
15
16
|
/** Panel width on non-small screens (number = px, or a `%` string). Full-width on small screens. */
|
|
16
17
|
width?: number | `${number}%`;
|
|
18
|
+
/**
|
|
19
|
+
* Record sequencing — step to the previous/next record WITHOUT closing the
|
|
20
|
+
* drawer (the triage rhythm: open once, work the whole list). Renders
|
|
21
|
+
* ◀ ▶ chevrons in the header and binds ←/→ while the drawer is open
|
|
22
|
+
* (ignored while typing in a field). Omit on a drawer that isn't one of
|
|
23
|
+
* a list. Pass `undefined` at either end of the list to disable that side.
|
|
24
|
+
*/
|
|
25
|
+
onPrev?: () => void;
|
|
26
|
+
onNext?: () => void;
|
|
27
|
+
/** Position caption between the chevrons ("3/24"). */
|
|
28
|
+
position?: string;
|
|
17
29
|
/**
|
|
18
30
|
* Drawer body. Fills the panel below the header as a plain flex column — lay
|
|
19
31
|
* out your own scroll area and pinned footer (e.g. a thread that scrolls above
|
|
@@ -32,10 +44,37 @@ export interface DrawerProps {
|
|
|
32
44
|
* full-width on small screens.
|
|
33
45
|
*/
|
|
34
46
|
export function Drawer(props: DrawerProps) {
|
|
35
|
-
const { open, onOpenChange, title, width = 420, children, testID } = props;
|
|
47
|
+
const { open, onOpenChange, title, width = 420, onPrev, onNext, position, children, testID } = props;
|
|
36
48
|
const screenSize = useScreenSize();
|
|
37
49
|
useOverlayScope(open);
|
|
38
50
|
const handleClose = () => onOpenChange(false);
|
|
51
|
+
const hasNav = onPrev !== undefined || onNext !== undefined || position !== undefined;
|
|
52
|
+
|
|
53
|
+
// ←/→ step records while the drawer is open — but never while the user is
|
|
54
|
+
// typing in a field (arrow keys belong to the caret there).
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!open || (!onPrev && !onNext)) return;
|
|
57
|
+
if (typeof document === "undefined") return;
|
|
58
|
+
const handler = (e: KeyboardEvent) => {
|
|
59
|
+
const t = document.activeElement;
|
|
60
|
+
if (
|
|
61
|
+
t instanceof HTMLElement &&
|
|
62
|
+
(t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)
|
|
63
|
+
) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (e.key === "ArrowLeft" && onPrev) {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
onPrev();
|
|
69
|
+
}
|
|
70
|
+
if (e.key === "ArrowRight" && onNext) {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
onNext();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
document.addEventListener("keydown", handler);
|
|
76
|
+
return () => document.removeEventListener("keydown", handler);
|
|
77
|
+
}, [open, onPrev, onNext]);
|
|
39
78
|
|
|
40
79
|
return (
|
|
41
80
|
<Modal visible={open} onRequestClose={handleClose} transparent>
|
|
@@ -52,6 +91,27 @@ export function Drawer(props: DrawerProps) {
|
|
|
52
91
|
) : (
|
|
53
92
|
<View style={{ flex: 1 }}>{title}</View>
|
|
54
93
|
)}
|
|
94
|
+
{hasNav ? (
|
|
95
|
+
<View style={styles.nav}>
|
|
96
|
+
<IconButton
|
|
97
|
+
icon="chevron-left"
|
|
98
|
+
accessibilityLabel="Previous record"
|
|
99
|
+
onPress={onPrev ?? (() => {})}
|
|
100
|
+
disabled={!onPrev}
|
|
101
|
+
/>
|
|
102
|
+
{position ? (
|
|
103
|
+
<Text size="xs" color="muted" tabular>
|
|
104
|
+
{position}
|
|
105
|
+
</Text>
|
|
106
|
+
) : null}
|
|
107
|
+
<IconButton
|
|
108
|
+
icon="chevron-right"
|
|
109
|
+
accessibilityLabel="Next record"
|
|
110
|
+
onPress={onNext ?? (() => {})}
|
|
111
|
+
disabled={!onNext}
|
|
112
|
+
/>
|
|
113
|
+
</View>
|
|
114
|
+
) : null}
|
|
55
115
|
<Button icon="x" accessibilityLabel="Close" onPress={handleClose} />
|
|
56
116
|
</View>
|
|
57
117
|
<View testID={testID} style={styles.body}>
|
|
@@ -64,7 +124,34 @@ export function Drawer(props: DrawerProps) {
|
|
|
64
124
|
);
|
|
65
125
|
}
|
|
66
126
|
|
|
127
|
+
export interface DrawerFooterProps {
|
|
128
|
+
/** The action(s) — typically right-aligned `Button`s. Prepend a
|
|
129
|
+
* `<Text style={{ flex: 1 }}>` hint to push them right with a summary on the left. */
|
|
130
|
+
children: ReactNode;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The pinned action bar at the bottom of a `Drawer` — a hairline-topped band (the
|
|
135
|
+
* same chrome as `CardFooter` / `DialogFooter`) that commits the record's action.
|
|
136
|
+
* Render it as the LAST child of the `Drawer`, after the scroll body; the body's
|
|
137
|
+
* `flex:1` pins it to the panel bottom. Actions sit right; a leading
|
|
138
|
+
* `<Text style={{ flex: 1 }}>` hint pushes them there.
|
|
139
|
+
*/
|
|
140
|
+
export function DrawerFooter(props: DrawerFooterProps) {
|
|
141
|
+
return <View style={styles.footer}>{props.children}</View>;
|
|
142
|
+
}
|
|
143
|
+
|
|
67
144
|
const styles = StyleSheet.create({
|
|
145
|
+
footer: {
|
|
146
|
+
borderTopWidth: 1,
|
|
147
|
+
borderTopColor: colors.border,
|
|
148
|
+
paddingHorizontal: 20,
|
|
149
|
+
paddingVertical: 14,
|
|
150
|
+
flexDirection: "row",
|
|
151
|
+
alignItems: "center",
|
|
152
|
+
justifyContent: "flex-end",
|
|
153
|
+
gap: 12,
|
|
154
|
+
},
|
|
68
155
|
base: {
|
|
69
156
|
height: "100%",
|
|
70
157
|
width: "100%",
|
|
@@ -92,6 +179,11 @@ const styles = StyleSheet.create({
|
|
|
92
179
|
paddingBottom: 8,
|
|
93
180
|
minHeight: 56,
|
|
94
181
|
},
|
|
182
|
+
nav: {
|
|
183
|
+
flexDirection: "row",
|
|
184
|
+
alignItems: "center",
|
|
185
|
+
gap: 2,
|
|
186
|
+
},
|
|
95
187
|
body: {
|
|
96
188
|
flex: 1,
|
|
97
189
|
},
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { View, StyleSheet } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
|
|
4
|
+
interface EmptyStateProps {
|
|
5
|
+
/** What's empty, in plain language ("Không có nhân viên nào khớp"). */
|
|
6
|
+
message: string;
|
|
7
|
+
/** What the user can do about it ("Thử từ khóa khác"). */
|
|
8
|
+
hint?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Centered placeholder for an empty list/filter result. Generous vertical
|
|
13
|
+
* space on purpose — an empty region that collapses to nothing reads as a
|
|
14
|
+
* rendering bug, not a state.
|
|
15
|
+
*/
|
|
16
|
+
export function EmptyState(props: EmptyStateProps) {
|
|
17
|
+
return (
|
|
18
|
+
<View style={styles.container}>
|
|
19
|
+
<Text size="sm" color="muted">
|
|
20
|
+
{props.message}
|
|
21
|
+
</Text>
|
|
22
|
+
{props.hint ? (
|
|
23
|
+
<Text size="xs" color="muted">
|
|
24
|
+
{props.hint}
|
|
25
|
+
</Text>
|
|
26
|
+
) : null}
|
|
27
|
+
</View>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const styles = StyleSheet.create({
|
|
32
|
+
container: {
|
|
33
|
+
paddingVertical: 48,
|
|
34
|
+
alignItems: "center",
|
|
35
|
+
gap: 4,
|
|
36
|
+
},
|
|
37
|
+
});
|
package/src/file_badge.tsx
CHANGED
|
@@ -35,11 +35,15 @@ export function resolveMime(mimeType: string): { label: string; color: string }
|
|
|
35
35
|
const DEFAULT_SIZE = 26;
|
|
36
36
|
|
|
37
37
|
interface FileBadgeProps {
|
|
38
|
-
|
|
38
|
+
/** Omit only with `placeholder` — a ghost slot has no resolved type yet. */
|
|
39
|
+
mimeType?: string;
|
|
39
40
|
/** Base width in pixels. Height, radii, font, and padding scale proportionally. Default: 26 */
|
|
40
41
|
size?: number;
|
|
41
42
|
/** Show a "TMPL" overlay to distinguish templates from regular files. */
|
|
42
43
|
isTemplate?: boolean;
|
|
44
|
+
/** Render a same-footprint dashed ghost for a not-yet-uploaded slot, so a
|
|
45
|
+
* pending row lines up pixel-for-pixel with a real badge. */
|
|
46
|
+
placeholder?: boolean;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
/**
|
|
@@ -52,11 +56,30 @@ function getMediaIcon(mimeType: string): IconName | undefined {
|
|
|
52
56
|
return undefined;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
export function FileBadge({ mimeType, size = DEFAULT_SIZE, isTemplate }: FileBadgeProps) {
|
|
56
|
-
const { label, color } = resolveMime(mimeType);
|
|
59
|
+
export function FileBadge({ mimeType, size = DEFAULT_SIZE, isTemplate, placeholder }: FileBadgeProps) {
|
|
57
60
|
const scale = size / DEFAULT_SIZE;
|
|
58
61
|
const height = Math.round(32 * scale);
|
|
59
62
|
const radius = Math.round(4 * scale);
|
|
63
|
+
|
|
64
|
+
if (placeholder) {
|
|
65
|
+
return (
|
|
66
|
+
<View style={{
|
|
67
|
+
width: size,
|
|
68
|
+
height,
|
|
69
|
+
borderRadius: radius,
|
|
70
|
+
borderWidth: Math.max(1, Math.round(1.5 * scale)),
|
|
71
|
+
borderColor: "#d4d4d8",
|
|
72
|
+
borderStyle: "dashed",
|
|
73
|
+
backgroundColor: "#fafafa",
|
|
74
|
+
alignItems: "center",
|
|
75
|
+
justifyContent: "center",
|
|
76
|
+
}}>
|
|
77
|
+
<Icon name="file-text" size={Math.max(10, Math.round(14 * scale))} color="#a1a1aa" />
|
|
78
|
+
</View>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { label, color } = resolveMime(mimeType ?? "");
|
|
60
83
|
const bottomHeight = Math.round(14 * scale);
|
|
61
84
|
const fontSize = Math.round(7 * scale);
|
|
62
85
|
const lineHeight = Math.round(10 * scale);
|
|
@@ -68,7 +91,7 @@ export function FileBadge({ mimeType, size = DEFAULT_SIZE, isTemplate }: FileBad
|
|
|
68
91
|
const tmplPadH = Math.round(2 * scale);
|
|
69
92
|
const tmplPadV = Math.max(1, Math.round(1 * scale));
|
|
70
93
|
const tmplRadius = Math.round(2 * scale);
|
|
71
|
-
const mediaIcon = getMediaIcon(mimeType);
|
|
94
|
+
const mediaIcon = getMediaIcon(mimeType ?? "");
|
|
72
95
|
const mediaIconSize = Math.max(10, Math.round(14 * scale));
|
|
73
96
|
|
|
74
97
|
return (
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { Text } from "./text";
|
|
6
|
+
import { pickFiles } from "./file_picker";
|
|
7
|
+
|
|
8
|
+
export interface FileDropzoneProps {
|
|
9
|
+
/** Receives the picked/dropped files. Bytes/upload belong to the host —
|
|
10
|
+
* this component only captures the files. */
|
|
11
|
+
onFiles: (files: File[]) => void;
|
|
12
|
+
/** Native `accept` filter (e.g. `"application/pdf,image/*"`); also applied
|
|
13
|
+
* to dropped files. */
|
|
14
|
+
accept?: string;
|
|
15
|
+
/** Allow more than one file. Default true. */
|
|
16
|
+
multiple?: boolean;
|
|
17
|
+
/** Main line ("Kéo thả tệp vào đây"). */
|
|
18
|
+
label?: string;
|
|
19
|
+
/** Secondary line ("hoặc bấm để chọn tệp · PDF, ảnh"). */
|
|
20
|
+
hint?: string;
|
|
21
|
+
/** Main line while a drag hovers the zone ("Thả để tải lên"). */
|
|
22
|
+
dropLabel?: string;
|
|
23
|
+
/** Zone height — size it to the surface (compact 120 in a form field,
|
|
24
|
+
* 200+ as a screen's main affordance). Default 160. */
|
|
25
|
+
height?: number;
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
accessibilityLabel?: string;
|
|
28
|
+
style?: StyleProp<ViewStyle>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function matchesAccept(file: File, accept: string | undefined): boolean {
|
|
32
|
+
if (!accept) return true;
|
|
33
|
+
const patterns = accept.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
|
|
34
|
+
if (patterns.length === 0) return true;
|
|
35
|
+
const mime = file.type.toLowerCase();
|
|
36
|
+
const name = file.name.toLowerCase();
|
|
37
|
+
return patterns.some((p) => {
|
|
38
|
+
if (p.startsWith(".")) return name.endsWith(p);
|
|
39
|
+
if (p.endsWith("/*")) return mime.startsWith(p.slice(0, -1));
|
|
40
|
+
return mime === p;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Drag-and-drop upload zone — the capture half of the file story (display =
|
|
46
|
+
* FileThumbnailGrid/FilePreview, picking = pickFiles, which also backs this
|
|
47
|
+
* zone's click). A dashed well with an icon + invitation; while a drag
|
|
48
|
+
* hovers it, the zone lights up in the accent and the label flips to
|
|
49
|
+
* `dropLabel`. On native (no drag events) it degrades to press-to-pick.
|
|
50
|
+
*/
|
|
51
|
+
export function FileDropzone(props: FileDropzoneProps) {
|
|
52
|
+
const {
|
|
53
|
+
onFiles,
|
|
54
|
+
accept,
|
|
55
|
+
multiple = true,
|
|
56
|
+
label = "Drag files here",
|
|
57
|
+
hint = "or click to browse",
|
|
58
|
+
dropLabel = "Drop to upload",
|
|
59
|
+
height = 160,
|
|
60
|
+
disabled = false,
|
|
61
|
+
accessibilityLabel,
|
|
62
|
+
style,
|
|
63
|
+
} = props;
|
|
64
|
+
|
|
65
|
+
const zoneRef = useRef<View>(null);
|
|
66
|
+
const dragDepth = useRef(0);
|
|
67
|
+
const [dragging, setDragging] = useState(false);
|
|
68
|
+
const [hovered, setHovered] = useState(false);
|
|
69
|
+
|
|
70
|
+
// RN-web exposes the View's underlying HTMLElement through the ref — attach
|
|
71
|
+
// the DOM drag events there. Native platforms skip this entirely (press-to-
|
|
72
|
+
// pick still works).
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (disabled) return;
|
|
75
|
+
if (typeof HTMLElement === "undefined") return;
|
|
76
|
+
const node = zoneRef.current;
|
|
77
|
+
if (!(node instanceof HTMLElement)) return;
|
|
78
|
+
|
|
79
|
+
const onDragEnter = (e: DragEvent) => {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
dragDepth.current += 1;
|
|
82
|
+
setDragging(true);
|
|
83
|
+
};
|
|
84
|
+
const onDragOver = (e: DragEvent) => {
|
|
85
|
+
// preventDefault is what makes the element a legal drop target.
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
};
|
|
88
|
+
const onDragLeave = () => {
|
|
89
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
90
|
+
if (dragDepth.current === 0) setDragging(false);
|
|
91
|
+
};
|
|
92
|
+
const onDrop = (e: DragEvent) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
dragDepth.current = 0;
|
|
95
|
+
setDragging(false);
|
|
96
|
+
const dropped = Array.from(e.dataTransfer?.files ?? []).filter((f) => matchesAccept(f, accept));
|
|
97
|
+
if (dropped.length === 0) return;
|
|
98
|
+
onFiles(multiple ? dropped : dropped.slice(0, 1));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
node.addEventListener("dragenter", onDragEnter);
|
|
102
|
+
node.addEventListener("dragover", onDragOver);
|
|
103
|
+
node.addEventListener("dragleave", onDragLeave);
|
|
104
|
+
node.addEventListener("drop", onDrop);
|
|
105
|
+
return () => {
|
|
106
|
+
node.removeEventListener("dragenter", onDragEnter);
|
|
107
|
+
node.removeEventListener("dragover", onDragOver);
|
|
108
|
+
node.removeEventListener("dragleave", onDragLeave);
|
|
109
|
+
node.removeEventListener("drop", onDrop);
|
|
110
|
+
};
|
|
111
|
+
}, [accept, disabled, multiple, onFiles]);
|
|
112
|
+
|
|
113
|
+
const handlePick = async () => {
|
|
114
|
+
if (disabled) return;
|
|
115
|
+
const picked = await pickFiles({ accept, multiple });
|
|
116
|
+
if (picked.length > 0) onFiles(picked);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<Pressable
|
|
121
|
+
ref={zoneRef}
|
|
122
|
+
accessibilityRole="button"
|
|
123
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
124
|
+
disabled={disabled}
|
|
125
|
+
onPress={handlePick}
|
|
126
|
+
onHoverIn={() => setHovered(true)}
|
|
127
|
+
onHoverOut={() => setHovered(false)}
|
|
128
|
+
style={[
|
|
129
|
+
styles.zone,
|
|
130
|
+
{ height },
|
|
131
|
+
hovered && !dragging ? styles.zoneHovered : null,
|
|
132
|
+
dragging ? styles.zoneDragging : null,
|
|
133
|
+
disabled ? styles.zoneDisabled : null,
|
|
134
|
+
style,
|
|
135
|
+
]}
|
|
136
|
+
>
|
|
137
|
+
<View style={[styles.iconWell, dragging ? styles.iconWellDragging : null]}>
|
|
138
|
+
<Icon name="upload" size={20} color={dragging ? colors.blue[600] : colors.zinc[500]} />
|
|
139
|
+
</View>
|
|
140
|
+
<Text size="sm" weight="medium" style={dragging ? { color: colors.blue[700] } : undefined}>
|
|
141
|
+
{dragging ? dropLabel : label}
|
|
142
|
+
</Text>
|
|
143
|
+
{!dragging && hint ? (
|
|
144
|
+
<Text size="xs" color="muted">
|
|
145
|
+
{hint}
|
|
146
|
+
</Text>
|
|
147
|
+
) : null}
|
|
148
|
+
</Pressable>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const styles = StyleSheet.create({
|
|
153
|
+
zone: {
|
|
154
|
+
alignItems: "center",
|
|
155
|
+
justifyContent: "center",
|
|
156
|
+
gap: 6,
|
|
157
|
+
borderRadius: 12,
|
|
158
|
+
borderWidth: 1.5,
|
|
159
|
+
borderStyle: "dashed",
|
|
160
|
+
borderColor: colors.zinc[300],
|
|
161
|
+
backgroundColor: colors.zinc[50],
|
|
162
|
+
paddingHorizontal: 20,
|
|
163
|
+
},
|
|
164
|
+
zoneHovered: {
|
|
165
|
+
borderColor: colors.zinc[400],
|
|
166
|
+
backgroundColor: colors.zinc[100],
|
|
167
|
+
},
|
|
168
|
+
// The drop invitation: accent border + tinted well — unmistakably "this is
|
|
169
|
+
// where your files land".
|
|
170
|
+
zoneDragging: {
|
|
171
|
+
borderColor: colors.blue[500],
|
|
172
|
+
backgroundColor: colors.blue[50],
|
|
173
|
+
},
|
|
174
|
+
zoneDisabled: {
|
|
175
|
+
opacity: 0.5,
|
|
176
|
+
},
|
|
177
|
+
iconWell: {
|
|
178
|
+
width: 40,
|
|
179
|
+
height: 40,
|
|
180
|
+
borderRadius: 20,
|
|
181
|
+
alignItems: "center",
|
|
182
|
+
justifyContent: "center",
|
|
183
|
+
backgroundColor: colors.zinc[100],
|
|
184
|
+
},
|
|
185
|
+
iconWellDragging: {
|
|
186
|
+
backgroundColor: colors.blue[100],
|
|
187
|
+
},
|
|
188
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Pure web file-pick primitive — the missing trigger half of the file story
|
|
2
|
+
// (display = file_thumbnail/file_preview, bytes = the host SDK's uploader,
|
|
3
|
+
// download = download.ts). Opens the browser's file dialog imperatively so
|
|
4
|
+
// RN-Web consumers never have to smuggle a DOM <input type="file"> into
|
|
5
|
+
// their component tree.
|
|
6
|
+
//
|
|
7
|
+
// The input is parked in the document (hidden) for the dialog's lifetime —
|
|
8
|
+
// some browsers GC a detached input while the dialog is still open, which
|
|
9
|
+
// silently drops the change event — and removed once the pick settles.
|
|
10
|
+
//
|
|
11
|
+
// Cancel detection uses the `cancel` event (Chrome 113+ / Safari 16.4+ /
|
|
12
|
+
// Firefox 91+), resolving `[]`. On older engines the promise simply never
|
|
13
|
+
// settles — callers treat a pick as fire-and-handle, so a stranded promise
|
|
14
|
+
// leaks nothing.
|
|
15
|
+
//
|
|
16
|
+
// No React Native / @lotics/shared imports — kept pure so both the host
|
|
17
|
+
// frontend and sandboxed custom-code apps can consume via the per-file
|
|
18
|
+
// export without dragging the wider UI surface.
|
|
19
|
+
|
|
20
|
+
export interface PickFilesOptions {
|
|
21
|
+
/** Native `accept` filter, e.g. `"application/pdf,image/*"`. */
|
|
22
|
+
accept?: string;
|
|
23
|
+
/** Allow picking more than one file. Default false. */
|
|
24
|
+
multiple?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function pickFiles(opts?: PickFilesOptions): Promise<File[]> {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const input = document.createElement("input");
|
|
30
|
+
input.type = "file";
|
|
31
|
+
if (opts?.accept) input.accept = opts.accept;
|
|
32
|
+
input.multiple = opts?.multiple ?? false;
|
|
33
|
+
input.style.display = "none";
|
|
34
|
+
|
|
35
|
+
const settle = (files: File[]) => {
|
|
36
|
+
input.remove();
|
|
37
|
+
resolve(files);
|
|
38
|
+
};
|
|
39
|
+
input.addEventListener("change", () => settle(Array.from(input.files ?? [])));
|
|
40
|
+
input.addEventListener("cancel", () => settle([]));
|
|
41
|
+
|
|
42
|
+
document.body.appendChild(input);
|
|
43
|
+
input.click();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { colors } from "./colors";
|
|
6
|
+
import { LinkButton } from "./link_button";
|
|
7
|
+
import { PillButton } from "./pill_button";
|
|
8
|
+
import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
|
|
9
|
+
import type { PopoverSide, PopoverAlign } from "./popover";
|
|
10
|
+
|
|
11
|
+
export interface FilterPillProps {
|
|
12
|
+
/** The dimension name — shown alone when inactive ("Owner"), prefixed when
|
|
13
|
+
* active ("Owner: Maria, James"). */
|
|
14
|
+
label: string;
|
|
15
|
+
/** A short human summary of the active selection ("Maria, James", "≥ 10").
|
|
16
|
+
* Empty / undefined renders the inactive pill (label + chevron, no clear).
|
|
17
|
+
* Build it with `rangeSummary` / `selectSummary` for a consistent preview. */
|
|
18
|
+
summary?: string;
|
|
19
|
+
/** Clears the dimension — renders the × on the pill AND a Clear in the popover
|
|
20
|
+
* footer whenever a summary is present. */
|
|
21
|
+
onClear?: () => void;
|
|
22
|
+
/** Label for the clear affordances (pass a translated string). */
|
|
23
|
+
clearLabel?: string;
|
|
24
|
+
/** The editor revealed on press — a premium primitive (`RangeSlider`,
|
|
25
|
+
* `Counter`, `PickerMenu` multi) or any composed control. `FilterPill` owns
|
|
26
|
+
* the pill + popover chrome; the consumer owns the value and applies it. */
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
side?: PopoverSide;
|
|
29
|
+
align?: PopoverAlign;
|
|
30
|
+
/** Optional controlled popover state — for an editor that closes on Save. */
|
|
31
|
+
open?: boolean;
|
|
32
|
+
onOpenChange?: (open: boolean) => void;
|
|
33
|
+
/** A custom popover footer (e.g. Cancel / Save) — replaces the baked Clear.
|
|
34
|
+
* Lets a non-filter VALUE pill (a setting gate) reuse the same shell. */
|
|
35
|
+
footer?: ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A short summary of a multi-select for a `FilterPill` preview — "Maria, James"
|
|
40
|
+
* up to `max` labels, else "N selected", and `undefined` when nothing is
|
|
41
|
+
* chosen. The select sibling of `rangeSummary`.
|
|
42
|
+
*/
|
|
43
|
+
export function selectSummary(
|
|
44
|
+
selected: string[],
|
|
45
|
+
options: { value: string; label: string }[],
|
|
46
|
+
opts?: { max?: number },
|
|
47
|
+
): string | undefined {
|
|
48
|
+
if (selected.length === 0) return undefined;
|
|
49
|
+
const max = opts?.max ?? 2;
|
|
50
|
+
if (selected.length > max) return `${selected.length} selected`;
|
|
51
|
+
return selected.map((v) => options.find((o) => o.value === v)?.label ?? v).join(", ");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A toolbar filter pill — the compact, popover-backed control for a SECONDARY
|
|
56
|
+
* filter dimension, the sibling of `ChipGroup` (which lays ONE hot dimension's
|
|
57
|
+
* options out inline). Many dimensions stay scannable because each collapses to
|
|
58
|
+
* a single pill: inactive reads "Label ⌄", active reads "Label: summary" with an
|
|
59
|
+
* × to clear. `FilterPill` bakes the consistent chrome — the preview pill, a
|
|
60
|
+
* padded popover body for the composed editor, and a Clear footer when active —
|
|
61
|
+
* so every filter looks and behaves the same. Drop a premium primitive inside
|
|
62
|
+
* (`RangeSlider`, `Counter`, `PickerMenu` multi). With `footer` (+ controlled
|
|
63
|
+
* `open`/`onOpenChange`) the same shell wraps a non-filter VALUE pill — a
|
|
64
|
+
* setting gate with Cancel/Save — keeping it on the one pill surface. The table
|
|
65
|
+
* toolbar's `ColumnFilter` is this pill plus its query-condition mapping.
|
|
66
|
+
*/
|
|
67
|
+
export function FilterPill(props: FilterPillProps) {
|
|
68
|
+
const { label, summary, onClear, clearLabel = "Clear", children, side = "bottom", align = "start", open, onOpenChange, footer } = props;
|
|
69
|
+
const active = summary != null && summary.length > 0;
|
|
70
|
+
// The clear × / Clear footer only when there's a clearable selection AND no
|
|
71
|
+
// custom footer — a valued, non-clearable pill ("Target: 20") keeps its chevron.
|
|
72
|
+
const showClear = active && !!onClear && !footer;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Popover side={side} align={align} open={open} onOpenChange={onOpenChange}>
|
|
76
|
+
<PopoverTrigger>
|
|
77
|
+
<PillButton onDismiss={showClear ? onClear : undefined} dismissTooltip={clearLabel}>
|
|
78
|
+
<Text size="sm" weight="medium" color={active ? "zinc-900" : "zinc-700"} numberOfLines={1}>
|
|
79
|
+
{active ? `${label}: ${summary}` : label}
|
|
80
|
+
</Text>
|
|
81
|
+
{!showClear ? <Icon name="chevron-down" size={14} color={colors.zinc["400"]} /> : null}
|
|
82
|
+
</PillButton>
|
|
83
|
+
</PopoverTrigger>
|
|
84
|
+
<PopoverContent style={styles.body} disableBodyScroll>
|
|
85
|
+
{children}
|
|
86
|
+
{footer ? (
|
|
87
|
+
<PopoverFooter>{footer}</PopoverFooter>
|
|
88
|
+
) : showClear ? (
|
|
89
|
+
<PopoverFooter align="start">
|
|
90
|
+
<LinkButton title={clearLabel} onPress={onClear} />
|
|
91
|
+
</PopoverFooter>
|
|
92
|
+
) : null}
|
|
93
|
+
</PopoverContent>
|
|
94
|
+
</Popover>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const styles = StyleSheet.create({
|
|
99
|
+
// The popover hugs its content — a wide control (e.g. RangeSlider) sets its
|
|
100
|
+
// OWN fixed width; the shell never forces one. No extra padding: the editor and
|
|
101
|
+
// the Clear footer then share the popover's own 8px inset, so a multi-select's
|
|
102
|
+
// options, its select-all, and the Clear all line up on one left edge.
|
|
103
|
+
body: {
|
|
104
|
+
gap: 8,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { Card } from "./card";
|
|
5
|
+
import { Text } from "./text";
|
|
6
|
+
|
|
7
|
+
export interface FloatingActionBarProps {
|
|
8
|
+
/** How many rows are selected — the bar shows only while > 0. */
|
|
9
|
+
count: number;
|
|
10
|
+
/** The noun after the count — "leads selected", "in-policy requests". */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Clears the selection — the always-present escape. */
|
|
13
|
+
onClear: () => void;
|
|
14
|
+
clearLabel?: string;
|
|
15
|
+
/** The bulk action(s) — a primary `Button` or an "Assign to…" `Popover`. */
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The floating action bar — pinned bottom-center while ≥1 row is selected across a
|
|
21
|
+
* checkbox-select register, showing the count + Clear + the bulk action(s), with
|
|
22
|
+
* the primary action kept RIGHTMOST (Clear is the secondary escape to its left).
|
|
23
|
+
* The shared surface for assign/reassign (a CRM pile), bulk-approve, batch-build,
|
|
24
|
+
* etc. Lift the selection `Set` into the host; this is presentation only. The
|
|
25
|
+
* `box-none` wrapper lets clicks fall through everywhere except the bar.
|
|
26
|
+
*/
|
|
27
|
+
export function FloatingActionBar(props: FloatingActionBarProps) {
|
|
28
|
+
const { count, label, onClear, clearLabel = "Clear", children } = props;
|
|
29
|
+
if (count <= 0) return null;
|
|
30
|
+
return (
|
|
31
|
+
<View pointerEvents="box-none" style={styles.wrap}>
|
|
32
|
+
<Card style={styles.bar}>
|
|
33
|
+
<Text size="sm" weight="semibold" tabular>{`${count} ${label}`}</Text>
|
|
34
|
+
<Button title={clearLabel} color="secondary" onPress={onClear} />
|
|
35
|
+
{children}
|
|
36
|
+
</Card>
|
|
37
|
+
</View>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const styles = StyleSheet.create({
|
|
42
|
+
wrap: {
|
|
43
|
+
position: "absolute",
|
|
44
|
+
bottom: 20,
|
|
45
|
+
left: 0,
|
|
46
|
+
right: 0,
|
|
47
|
+
alignItems: "center",
|
|
48
|
+
zIndex: 20,
|
|
49
|
+
},
|
|
50
|
+
bar: {
|
|
51
|
+
flexDirection: "row",
|
|
52
|
+
alignItems: "center",
|
|
53
|
+
gap: 12,
|
|
54
|
+
paddingVertical: 10,
|
|
55
|
+
paddingHorizontal: 16,
|
|
56
|
+
},
|
|
57
|
+
});
|