@lotics/ui 2.4.0 → 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.
Files changed (69) hide show
  1. package/package.json +27 -8
  2. package/src/accordion.tsx +146 -63
  3. package/src/action_menu.tsx +72 -0
  4. package/src/allocation_row.tsx +54 -0
  5. package/src/avatar.web.tsx +102 -0
  6. package/src/badge.tsx +40 -9
  7. package/src/breakdown.tsx +121 -0
  8. package/src/card.tsx +150 -0
  9. package/src/cell_select.tsx +3 -2
  10. package/src/chip_group.tsx +65 -0
  11. package/src/colors.ts +61 -0
  12. package/src/column_filter.tsx +9 -24
  13. package/src/completion_state.tsx +43 -0
  14. package/src/control_surface.ts +32 -0
  15. package/src/counter.tsx +58 -0
  16. package/src/date_range_filter_field.tsx +44 -12
  17. package/src/detail_row.tsx +45 -0
  18. package/src/dialog.tsx +0 -24
  19. package/src/download.ts +2 -1
  20. package/src/drawer.tsx +94 -2
  21. package/src/empty_state.tsx +37 -0
  22. package/src/file_badge.tsx +27 -4
  23. package/src/file_dropzone.tsx +188 -0
  24. package/src/file_picker.ts +45 -0
  25. package/src/filter_pill.tsx +106 -0
  26. package/src/floating_action_bar.tsx +57 -0
  27. package/src/fonts.css +10 -13
  28. package/src/format_money.ts +38 -0
  29. package/src/heatmap.tsx +153 -0
  30. package/src/icon.tsx +2 -0
  31. package/src/icon_button.tsx +16 -2
  32. package/src/index.css +4 -3
  33. package/src/info_popover.tsx +4 -6
  34. package/src/kpi_card.tsx +19 -6
  35. package/src/kpi_strip.tsx +89 -0
  36. package/src/line_chart.tsx +61 -34
  37. package/src/link_button.tsx +50 -0
  38. package/src/metric.tsx +21 -12
  39. package/src/pagination.tsx +5 -9
  40. package/src/peek.tsx +68 -0
  41. package/src/picker.tsx +13 -1
  42. package/src/picker_menu.tsx +8 -16
  43. package/src/pie_chart.tsx +29 -8
  44. package/src/pill_button.tsx +10 -8
  45. package/src/popover.tsx +14 -4
  46. package/src/pressable_highlight.tsx +10 -1
  47. package/src/pressable_row.tsx +91 -0
  48. package/src/progress_bar.tsx +47 -17
  49. package/src/radio_picker.tsx +20 -9
  50. package/src/range_slider.tsx +185 -0
  51. package/src/remainder_meter.tsx +48 -0
  52. package/src/ring_gauge.tsx +5 -5
  53. package/src/scan_field.tsx +58 -0
  54. package/src/search_input.tsx +12 -0
  55. package/src/sort_header.tsx +102 -0
  56. package/src/stacked_progress_bar.tsx +51 -16
  57. package/src/status_grid.tsx +187 -0
  58. package/src/step_list.tsx +128 -0
  59. package/src/step_progress.tsx +145 -0
  60. package/src/stepper.tsx +9 -4
  61. package/src/table.tsx +168 -112
  62. package/src/text.tsx +15 -0
  63. package/src/text_utils.ts +10 -0
  64. package/src/timeline.tsx +90 -57
  65. package/src/trend_footer.tsx +2 -2
  66. package/src/alert_row.tsx +0 -81
  67. package/src/table.web.tsx +0 -235
  68. package/src/table_picker.tsx +0 -305
  69. package/src/table_types.ts +0 -47
@@ -56,15 +56,53 @@ function formatDate(date: Date | null, locale: string | undefined): string {
56
56
  }
57
57
  }
58
58
 
59
+ /**
60
+ * Recognized whole periods display compactly — a range that IS a calendar
61
+ * month reads "Tháng 6 năm 2026" (sentence-cased via the locale), a whole
62
+ * year "2026", a single day one date. Anything else falls back to
63
+ * "start – end". Keeps the trigger scannable where dashboards live in
64
+ * period rhythm, not date pairs.
65
+ */
66
+ function formatRangeDisplay(start: Date, end: Date, locale: string | undefined): string {
67
+ if (start.toDateString() === end.toDateString()) return formatDate(start, locale);
68
+
69
+ const wholeMonth =
70
+ start.getDate() === 1 &&
71
+ start.getMonth() === end.getMonth() &&
72
+ start.getFullYear() === end.getFullYear() &&
73
+ end.getDate() === new Date(end.getFullYear(), end.getMonth() + 1, 0).getDate();
74
+ if (wholeMonth) {
75
+ try {
76
+ const label = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(start);
77
+ return label.charAt(0).toUpperCase() + label.slice(1);
78
+ } catch {
79
+ return `${start.getMonth() + 1}/${start.getFullYear()}`;
80
+ }
81
+ }
82
+
83
+ const wholeYear =
84
+ start.getFullYear() === end.getFullYear() &&
85
+ start.getMonth() === 0 &&
86
+ start.getDate() === 1 &&
87
+ end.getMonth() === 11 &&
88
+ end.getDate() === 31;
89
+ if (wholeYear) return String(start.getFullYear());
90
+
91
+ return `${formatDate(start, locale)} – ${formatDate(end, locale)}`;
92
+ }
93
+
59
94
  export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
60
95
  const { value, onValueChange, includeTime, locale, testID } = props;
61
96
  const labels = useMemo(() => ({ ...DEFAULT_FIELD_LABELS, ...props.labels }), [props.labels]);
62
97
  const [open, setOpen] = useState(false);
63
98
 
64
99
  const hasValue = Boolean(value.start.date || value.end.date);
65
- const display = hasValue
66
- ? `${formatDate(value.start.date, locale)} – ${formatDate(value.end.date, locale)}`
67
- : labels.placeholder;
100
+ const display =
101
+ value.start.date && value.end.date
102
+ ? formatRangeDisplay(value.start.date, value.end.date, locale)
103
+ : hasValue
104
+ ? `${formatDate(value.start.date, locale)} – ${formatDate(value.end.date, locale)}`
105
+ : labels.placeholder;
68
106
 
69
107
  return (
70
108
  <Popover open={open} onOpenChange={setOpen} side="bottom" align="start">
@@ -100,15 +138,9 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
100
138
  labels={props.labels}
101
139
  locale={locale}
102
140
  />
103
- <PopoverFooter>
104
- <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
105
- {hasValue ? (
106
- <Button title={labels.clear} onPress={() => onValueChange(EMPTY_VALUE)} />
107
- ) : (
108
- <View />
109
- )}
110
- <Button title={labels.done} color="secondary" onPress={() => setOpen(false)} />
111
- </View>
141
+ <PopoverFooter align="space-between">
142
+ {hasValue ? <Button title={labels.clear} onPress={() => onValueChange(EMPTY_VALUE)} /> : <View />}
143
+ <Button title={labels.done} color="secondary" onPress={() => setOpen(false)} />
112
144
  </PopoverFooter>
113
145
  </PopoverContent>
114
146
  </Popover>
@@ -0,0 +1,45 @@
1
+ import { ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { Text } from "./text";
4
+
5
+ export interface DetailRowProps {
6
+ /** The field label — rendered `sm muted`. */
7
+ label: ReactNode;
8
+ /** The value — any node (a `Text`, a `Badge`, a stack). */
9
+ children: ReactNode;
10
+ /** Fixed label-column width (px) to align labels in a left column — for a peek
11
+ * or form-like stack. Omit for a spread row: the label takes the slack and the
12
+ * value sits at the right edge (the default drawer-detail look). */
13
+ labelWidth?: number;
14
+ /** Label text size — `xs` for a compact peek, `sm` (default) for a drawer. */
15
+ labelSize?: "xs" | "sm";
16
+ /** Min row height. Default 28. */
17
+ minHeight?: number;
18
+ }
19
+
20
+ /**
21
+ * One label + value line for a drawer / peek detail view — NOT a list row (that's
22
+ * `PressableRow` / `TableRow`). Spread by default (muted label left, value pushed
23
+ * to the right edge); pass `labelWidth` to align labels in a fixed left column
24
+ * with the values flowing immediately after instead.
25
+ */
26
+ export function DetailRow(props: DetailRowProps) {
27
+ const { label, children, labelWidth, labelSize = "sm", minHeight = 28 } = props;
28
+ return (
29
+ <View style={[styles.row, { minHeight }]}>
30
+ <Text size={labelSize} color="muted" style={labelWidth != null ? { width: labelWidth } : styles.flexLabel}>
31
+ {label}
32
+ </Text>
33
+ {children}
34
+ </View>
35
+ );
36
+ }
37
+
38
+ const styles = StyleSheet.create({
39
+ row: {
40
+ flexDirection: "row",
41
+ alignItems: "center",
42
+ gap: 12,
43
+ },
44
+ flexLabel: { flex: 1 },
45
+ });
package/src/dialog.tsx CHANGED
@@ -178,30 +178,6 @@ export function Dialog(props: DialogProps) {
178
178
  );
179
179
  }
180
180
 
181
- // ============================================================================
182
- // DialogTrigger
183
- // ============================================================================
184
-
185
- export interface DialogTriggerProps {
186
- children: React.ReactElement;
187
- }
188
-
189
- export function DialogTrigger({ children }: DialogTriggerProps) {
190
- const { open, onOpenChange } = useDialog();
191
-
192
- const handlePress = useCallback(() => {
193
- onOpenChange(!open);
194
- }, [open, onOpenChange]);
195
-
196
- return React.cloneElement(
197
- children as React.ReactElement<{
198
- onPress?: () => void;
199
- }>,
200
- {
201
- onPress: handlePress,
202
- },
203
- );
204
- }
205
181
 
206
182
  // ============================================================================
207
183
  // DialogHeader Components (Composition-based)
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<void> {
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
+ });
@@ -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
- mimeType: string;
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
+ }