@lotics/ui 1.6.1 → 1.9.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 +22 -3
- package/src/alert.tsx +3 -0
- package/src/alert_row.tsx +81 -0
- package/src/card.tsx +14 -0
- package/src/cell_date_format.ts +15 -4
- package/src/chart_area.tsx +105 -0
- package/src/chart_bar.tsx +154 -0
- package/src/chart_internals.tsx +43 -0
- package/src/dialog.tsx +3 -0
- package/src/file_badge.tsx +160 -0
- package/src/file_gallery_modal.tsx +188 -0
- package/src/file_thumbnail.tsx +437 -0
- package/src/kpi_card.tsx +77 -0
- package/src/legend_item.tsx +47 -0
- package/src/metric.tsx +43 -4
- package/src/mime.ts +15 -0
- package/src/overlay_scope.ts +44 -0
- package/src/popover.tsx +3 -0
- package/src/section_card.tsx +68 -0
- package/src/spacing.ts +23 -0
- package/src/sparkline.tsx +85 -0
- package/src/stacked_progress_bar.tsx +65 -0
- package/src/text.css +20 -1
- package/src/theme.tsx +61 -0
- package/src/trend_chip.tsx +65 -0
- package/src/trend_footer.tsx +56 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { FileBadge, resolveMime } from "./file_badge";
|
|
2
|
+
import { fontFamilySemiBold } from "./text_utils";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Icon, type IconName } from "./icon";
|
|
5
|
+
import { Checkbox } from "./checkbox";
|
|
6
|
+
import { colors } from "./colors";
|
|
7
|
+
import { useCallback, useState } from "react";
|
|
8
|
+
import {
|
|
9
|
+
GestureResponderEvent,
|
|
10
|
+
Image,
|
|
11
|
+
Text as RNText,
|
|
12
|
+
View,
|
|
13
|
+
Pressable,
|
|
14
|
+
StyleSheet,
|
|
15
|
+
Linking,
|
|
16
|
+
} from "react-native";
|
|
17
|
+
import { isImageMimeType, isVideoMimeType, isAudioMimeType } from "./mime";
|
|
18
|
+
|
|
19
|
+
export const THUMBNAIL_SIZE = 96;
|
|
20
|
+
export const COMPACT_THUMBNAIL_SIZE = 32;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns the icon name for playable media (video/audio) so a MediaCard can
|
|
24
|
+
* render a play/music glyph instead of document placeholder lines. Returns
|
|
25
|
+
* undefined for non-media files, which should fall back to DocumentCard.
|
|
26
|
+
*/
|
|
27
|
+
export function getMediaIcon(mimeType: string): IconName | undefined {
|
|
28
|
+
if (isVideoMimeType(mimeType)) return "play";
|
|
29
|
+
if (isAudioMimeType(mimeType)) return "music";
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// =============================================================================
|
|
34
|
+
// Core Display Type
|
|
35
|
+
// =============================================================================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalized file format for display components.
|
|
39
|
+
* All file sources (database, chat messages, uploads) convert to this.
|
|
40
|
+
*/
|
|
41
|
+
export interface DisplayFile {
|
|
42
|
+
id: string;
|
|
43
|
+
filename: string;
|
|
44
|
+
mimeType: string;
|
|
45
|
+
/** Direct URL to the file */
|
|
46
|
+
url: string;
|
|
47
|
+
/** Optional thumbnail URL for images */
|
|
48
|
+
thumbnailUrl?: string;
|
|
49
|
+
/** Preview URL for legacy Excel files (.xls converted to .xlsx server-side) */
|
|
50
|
+
previewUrl?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// FileThumbnail - Main display component
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
interface FileThumbnailProps {
|
|
58
|
+
file: DisplayFile;
|
|
59
|
+
/** Explicit pixel size. When omitted, fills container (width: "100%", aspectRatio: 1). */
|
|
60
|
+
size?: number;
|
|
61
|
+
/** Called when thumbnail is pressed */
|
|
62
|
+
onPress?: () => void;
|
|
63
|
+
/** Called when thumbnail is long-pressed */
|
|
64
|
+
onLongPress?: () => void;
|
|
65
|
+
/** Called when the completed file is removed */
|
|
66
|
+
onRemove?: () => void;
|
|
67
|
+
/** When defined, renders a selection overlay with a checkbox. */
|
|
68
|
+
selected?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Displays a file as a square thumbnail.
|
|
73
|
+
* Works with any file source via DisplayFile interface.
|
|
74
|
+
*/
|
|
75
|
+
export function FileThumbnail(props: FileThumbnailProps) {
|
|
76
|
+
const { file, size, onPress, onLongPress, onRemove, selected } = props;
|
|
77
|
+
|
|
78
|
+
const rootStyle =
|
|
79
|
+
size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
|
|
80
|
+
|
|
81
|
+
if (isImageMimeType(file.mimeType)) {
|
|
82
|
+
return (
|
|
83
|
+
<View style={rootStyle}>
|
|
84
|
+
<ImageThumbnail file={file} size={size} onPress={onPress} onLongPress={onLongPress} />
|
|
85
|
+
{onRemove && <RemoveButton onPress={onRemove} />}
|
|
86
|
+
{selected !== undefined && <SelectionOverlay selected={selected} />}
|
|
87
|
+
</View>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const mediaIcon = getMediaIcon(file.mimeType);
|
|
92
|
+
|
|
93
|
+
if (size !== undefined && size <= COMPACT_THUMBNAIL_SIZE && mediaIcon === undefined) {
|
|
94
|
+
return (
|
|
95
|
+
<View style={rootStyle}>
|
|
96
|
+
<DocumentBadge
|
|
97
|
+
mimeType={file.mimeType}
|
|
98
|
+
size={size}
|
|
99
|
+
onPress={onPress ?? (() => Linking.openURL(file.url))}
|
|
100
|
+
onLongPress={onLongPress}
|
|
101
|
+
/>
|
|
102
|
+
{onRemove && <RemoveButton onPress={onRemove} />}
|
|
103
|
+
</View>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<View style={rootStyle}>
|
|
109
|
+
{mediaIcon !== undefined ? (
|
|
110
|
+
<MediaCard
|
|
111
|
+
mimeType={file.mimeType}
|
|
112
|
+
filename={file.filename}
|
|
113
|
+
icon={mediaIcon}
|
|
114
|
+
size={size}
|
|
115
|
+
onPress={onPress ?? (() => Linking.openURL(file.url))}
|
|
116
|
+
onLongPress={onLongPress}
|
|
117
|
+
/>
|
|
118
|
+
) : (
|
|
119
|
+
<DocumentCard
|
|
120
|
+
mimeType={file.mimeType}
|
|
121
|
+
filename={file.filename}
|
|
122
|
+
size={size}
|
|
123
|
+
onPress={onPress ?? (() => Linking.openURL(file.url))}
|
|
124
|
+
onLongPress={onLongPress}
|
|
125
|
+
/>
|
|
126
|
+
)}
|
|
127
|
+
{onRemove && <RemoveButton onPress={onRemove} />}
|
|
128
|
+
{selected !== undefined && <SelectionOverlay selected={selected} />}
|
|
129
|
+
</View>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// =============================================================================
|
|
134
|
+
// Base Components (exported for direct use when needed)
|
|
135
|
+
// =============================================================================
|
|
136
|
+
|
|
137
|
+
interface DocumentBadgeProps {
|
|
138
|
+
mimeType: string;
|
|
139
|
+
size?: number;
|
|
140
|
+
isTemplate?: boolean;
|
|
141
|
+
onPress?: () => void;
|
|
142
|
+
onLongPress?: () => void;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Compact two-tone file badge with document lines and colored label.
|
|
147
|
+
*/
|
|
148
|
+
export function DocumentBadge(props: DocumentBadgeProps) {
|
|
149
|
+
const { mimeType, isTemplate, onPress, onLongPress } = props;
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<Pressable onPress={onPress} onLongPress={onLongPress}>
|
|
153
|
+
<FileBadge mimeType={mimeType} isTemplate={isTemplate} />
|
|
154
|
+
</Pressable>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface DocumentCardProps {
|
|
159
|
+
mimeType: string;
|
|
160
|
+
filename: string;
|
|
161
|
+
size?: number;
|
|
162
|
+
onPress?: () => void;
|
|
163
|
+
onLongPress?: () => void;
|
|
164
|
+
overlay?: React.ReactNode;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Document preview card: placeholder lines (future: file preview) + footer with filename and type label.
|
|
169
|
+
*/
|
|
170
|
+
export function DocumentCard(props: DocumentCardProps) {
|
|
171
|
+
const { mimeType, filename, size, onPress, onLongPress, overlay } = props;
|
|
172
|
+
const { label, color } = resolveMime(mimeType);
|
|
173
|
+
|
|
174
|
+
const sizeStyle =
|
|
175
|
+
size !== undefined
|
|
176
|
+
? { width: size, height: size }
|
|
177
|
+
: { width: "100%" as const, height: "100%" as const };
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<Pressable style={[styles.documentCard, sizeStyle]} onPress={onPress} onLongPress={onLongPress}>
|
|
181
|
+
<View style={styles.documentCardBody}>
|
|
182
|
+
<View style={styles.placeholderLines}>
|
|
183
|
+
<View style={styles.placeholderLine} />
|
|
184
|
+
<View style={styles.placeholderLine} />
|
|
185
|
+
<View style={[styles.placeholderLine, { width: "60%" }]} />
|
|
186
|
+
<View style={styles.placeholderLine} />
|
|
187
|
+
<View style={[styles.placeholderLine, { width: "40%" }]} />
|
|
188
|
+
</View>
|
|
189
|
+
</View>
|
|
190
|
+
<View style={styles.documentCardFooter}>
|
|
191
|
+
<Text size="xs" numberOfLines={1} color="zinc-500" userSelect="none">
|
|
192
|
+
{filename}
|
|
193
|
+
</Text>
|
|
194
|
+
<View style={[styles.typeLabel, { backgroundColor: color }]}>
|
|
195
|
+
<RNText style={styles.typeLabelText}>{label}</RNText>
|
|
196
|
+
</View>
|
|
197
|
+
</View>
|
|
198
|
+
{overlay}
|
|
199
|
+
</Pressable>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
interface MediaCardProps {
|
|
204
|
+
mimeType: string;
|
|
205
|
+
filename: string;
|
|
206
|
+
/** Icon rendered in the body — typically "play" for video or "music" for audio. */
|
|
207
|
+
icon: IconName;
|
|
208
|
+
size?: number;
|
|
209
|
+
onPress?: () => void;
|
|
210
|
+
onLongPress?: () => void;
|
|
211
|
+
overlay?: React.ReactNode;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Playable-media preview card: colored body with a large icon (play/music) +
|
|
216
|
+
* footer with filename and type label. Visual counterpart to DocumentCard
|
|
217
|
+
* for videos and audio files.
|
|
218
|
+
*/
|
|
219
|
+
export function MediaCard(props: MediaCardProps) {
|
|
220
|
+
const { mimeType, filename, size, onPress, onLongPress, overlay, icon } = props;
|
|
221
|
+
const { label, color } = resolveMime(mimeType);
|
|
222
|
+
|
|
223
|
+
const sizeStyle =
|
|
224
|
+
size !== undefined
|
|
225
|
+
? { width: size, height: size }
|
|
226
|
+
: { width: "100%" as const, height: "100%" as const };
|
|
227
|
+
|
|
228
|
+
const isCompact = size !== undefined && size <= COMPACT_THUMBNAIL_SIZE;
|
|
229
|
+
|
|
230
|
+
if (isCompact) {
|
|
231
|
+
const iconSize = Math.max(12, Math.round(size * 0.55));
|
|
232
|
+
return (
|
|
233
|
+
<Pressable
|
|
234
|
+
style={[styles.mediaCardCompact, sizeStyle, { backgroundColor: color }]}
|
|
235
|
+
onPress={onPress}
|
|
236
|
+
onLongPress={onLongPress}
|
|
237
|
+
>
|
|
238
|
+
<Icon name={icon} size={iconSize} color={colors.white} />
|
|
239
|
+
{overlay}
|
|
240
|
+
</Pressable>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const iconSize = size !== undefined ? Math.max(20, Math.min(Math.round(size * 0.4), 56)) : 44;
|
|
245
|
+
const backdrop = iconSize + 20;
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<Pressable style={[styles.documentCard, sizeStyle]} onPress={onPress} onLongPress={onLongPress}>
|
|
249
|
+
<View style={[styles.mediaCardBody, { backgroundColor: color }]}>
|
|
250
|
+
<View style={[styles.mediaIconBackdrop, { width: backdrop, height: backdrop, borderRadius: backdrop / 2 }]}>
|
|
251
|
+
<Icon name={icon} size={iconSize} color={colors.white} />
|
|
252
|
+
</View>
|
|
253
|
+
</View>
|
|
254
|
+
<View style={styles.documentCardFooter}>
|
|
255
|
+
<Text size="xs" numberOfLines={1} color="zinc-500" userSelect="none">
|
|
256
|
+
{filename}
|
|
257
|
+
</Text>
|
|
258
|
+
<View style={[styles.typeLabel, { backgroundColor: color }]}>
|
|
259
|
+
<RNText style={styles.typeLabelText}>{label}</RNText>
|
|
260
|
+
</View>
|
|
261
|
+
</View>
|
|
262
|
+
{overlay}
|
|
263
|
+
</Pressable>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// =============================================================================
|
|
268
|
+
// Internal Components (exported so upload-aware wrappers in @lotics/ui-internal
|
|
269
|
+
// can reuse them without duplication)
|
|
270
|
+
// =============================================================================
|
|
271
|
+
|
|
272
|
+
interface ImageThumbnailProps {
|
|
273
|
+
file: DisplayFile;
|
|
274
|
+
/** Explicit pixel size. When omitted, fills container. */
|
|
275
|
+
size?: number;
|
|
276
|
+
onPress?: () => void;
|
|
277
|
+
onLongPress?: () => void;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function ImageThumbnail(props: ImageThumbnailProps) {
|
|
281
|
+
const { file, size, onPress, onLongPress } = props;
|
|
282
|
+
const [useFallback, setUseFallback] = useState(false);
|
|
283
|
+
|
|
284
|
+
// Use thumbnail if available, fallback to full URL
|
|
285
|
+
const url = !useFallback && file.thumbnailUrl ? file.thumbnailUrl : file.url;
|
|
286
|
+
|
|
287
|
+
const sizeStyle =
|
|
288
|
+
size !== undefined
|
|
289
|
+
? { width: size, height: size }
|
|
290
|
+
: { width: "100%" as const, height: "100%" as const };
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<Pressable
|
|
294
|
+
onPress={onPress}
|
|
295
|
+
onLongPress={onLongPress}
|
|
296
|
+
style={[styles.imageThumbnail, sizeStyle]}
|
|
297
|
+
>
|
|
298
|
+
<Image
|
|
299
|
+
source={{ uri: url }}
|
|
300
|
+
style={styles.image}
|
|
301
|
+
resizeMode="cover"
|
|
302
|
+
onError={() => setUseFallback(true)}
|
|
303
|
+
/>
|
|
304
|
+
</Pressable>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function RemoveButton({ onPress }: { onPress: () => void }) {
|
|
309
|
+
const handlePress = useCallback(
|
|
310
|
+
(e: GestureResponderEvent) => {
|
|
311
|
+
e.stopPropagation();
|
|
312
|
+
onPress();
|
|
313
|
+
},
|
|
314
|
+
[onPress],
|
|
315
|
+
);
|
|
316
|
+
return (
|
|
317
|
+
<Pressable onPress={handlePress} style={styles.removeButton}>
|
|
318
|
+
<Icon name="x" size={14} color={colors.zinc[700]} />
|
|
319
|
+
</Pressable>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function SelectionOverlay({ selected }: { selected: boolean }) {
|
|
324
|
+
return (
|
|
325
|
+
<View
|
|
326
|
+
style={[styles.selectionOverlay, selected && styles.selectionOverlaySelected]}
|
|
327
|
+
pointerEvents="none"
|
|
328
|
+
>
|
|
329
|
+
<View style={styles.selectionCheckbox}>
|
|
330
|
+
<Checkbox checked={selected} />
|
|
331
|
+
</View>
|
|
332
|
+
</View>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// =============================================================================
|
|
337
|
+
// Styles
|
|
338
|
+
// =============================================================================
|
|
339
|
+
|
|
340
|
+
const styles = StyleSheet.create({
|
|
341
|
+
documentCard: {
|
|
342
|
+
borderRadius: 8,
|
|
343
|
+
backgroundColor: colors.white,
|
|
344
|
+
borderWidth: 1,
|
|
345
|
+
borderColor: colors.zinc["200"],
|
|
346
|
+
overflow: "hidden",
|
|
347
|
+
},
|
|
348
|
+
documentCardBody: {
|
|
349
|
+
flex: 1,
|
|
350
|
+
justifyContent: "center",
|
|
351
|
+
padding: 10,
|
|
352
|
+
},
|
|
353
|
+
mediaCardBody: {
|
|
354
|
+
flex: 1,
|
|
355
|
+
justifyContent: "center",
|
|
356
|
+
alignItems: "center",
|
|
357
|
+
},
|
|
358
|
+
mediaCardCompact: {
|
|
359
|
+
borderRadius: 6,
|
|
360
|
+
alignItems: "center",
|
|
361
|
+
justifyContent: "center",
|
|
362
|
+
overflow: "hidden",
|
|
363
|
+
},
|
|
364
|
+
mediaIconBackdrop: {
|
|
365
|
+
alignItems: "center",
|
|
366
|
+
justifyContent: "center",
|
|
367
|
+
backgroundColor: "rgba(255, 255, 255, 0.22)",
|
|
368
|
+
},
|
|
369
|
+
placeholderLines: {
|
|
370
|
+
gap: 4,
|
|
371
|
+
},
|
|
372
|
+
placeholderLine: {
|
|
373
|
+
height: 2,
|
|
374
|
+
borderRadius: 1,
|
|
375
|
+
backgroundColor: colors.zinc["200"],
|
|
376
|
+
},
|
|
377
|
+
documentCardFooter: {
|
|
378
|
+
gap: 2,
|
|
379
|
+
paddingHorizontal: 8,
|
|
380
|
+
paddingVertical: 6,
|
|
381
|
+
borderTopWidth: 1,
|
|
382
|
+
backgroundColor: colors.zinc['50'],
|
|
383
|
+
borderTopColor: colors.zinc["200"],
|
|
384
|
+
alignItems: "flex-start",
|
|
385
|
+
},
|
|
386
|
+
typeLabel: {
|
|
387
|
+
borderRadius: 3,
|
|
388
|
+
paddingHorizontal: 4,
|
|
389
|
+
paddingVertical: 1,
|
|
390
|
+
},
|
|
391
|
+
typeLabelText: {
|
|
392
|
+
fontFamily: fontFamilySemiBold,
|
|
393
|
+
fontSize: 8,
|
|
394
|
+
fontWeight: "700",
|
|
395
|
+
color: "white",
|
|
396
|
+
letterSpacing: 0.3,
|
|
397
|
+
},
|
|
398
|
+
imageThumbnail: {
|
|
399
|
+
borderRadius: 8,
|
|
400
|
+
overflow: "hidden",
|
|
401
|
+
},
|
|
402
|
+
image: {
|
|
403
|
+
width: "100%",
|
|
404
|
+
height: "100%",
|
|
405
|
+
},
|
|
406
|
+
removeButton: {
|
|
407
|
+
position: "absolute",
|
|
408
|
+
top: -6,
|
|
409
|
+
right: -6,
|
|
410
|
+
width: 22,
|
|
411
|
+
height: 22,
|
|
412
|
+
alignItems: "center",
|
|
413
|
+
justifyContent: "center",
|
|
414
|
+
borderRadius: 999,
|
|
415
|
+
borderWidth: 1,
|
|
416
|
+
borderColor: colors.border,
|
|
417
|
+
backgroundColor: colors.zinc["50"],
|
|
418
|
+
zIndex: 1,
|
|
419
|
+
},
|
|
420
|
+
selectionOverlay: {
|
|
421
|
+
position: "absolute",
|
|
422
|
+
top: 0,
|
|
423
|
+
left: 0,
|
|
424
|
+
right: 0,
|
|
425
|
+
bottom: 0,
|
|
426
|
+
borderRadius: 8,
|
|
427
|
+
zIndex: 1,
|
|
428
|
+
},
|
|
429
|
+
selectionOverlaySelected: {
|
|
430
|
+
backgroundColor: "rgba(0, 0, 0, 0.15)",
|
|
431
|
+
},
|
|
432
|
+
selectionCheckbox: {
|
|
433
|
+
position: "absolute",
|
|
434
|
+
bottom: 4,
|
|
435
|
+
right: 4,
|
|
436
|
+
},
|
|
437
|
+
});
|
package/src/kpi_card.tsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { View, StyleSheet, type ViewStyle, type StyleProp } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { Metric, type MetricFormat, type MetricSize, type MetricTone } from "./metric";
|
|
4
|
+
import { TrendChip } from "./trend_chip";
|
|
5
|
+
import { SPACE } from "./spacing";
|
|
6
|
+
|
|
7
|
+
interface KPICardProps {
|
|
8
|
+
/** Short uppercase label above the value. Identifies the metric. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** The number to display. `null` renders as `emptyLabel`. */
|
|
11
|
+
value: number | string | null | undefined;
|
|
12
|
+
format?: MetricFormat;
|
|
13
|
+
currency?: string;
|
|
14
|
+
locale?: string;
|
|
15
|
+
emptyLabel?: string;
|
|
16
|
+
/** Numeric size hint. Default `lg`; pass `hero` for the dominant
|
|
17
|
+
* metric in a card; `md` for supporting metrics in a horizontal strip. */
|
|
18
|
+
size?: MetricSize;
|
|
19
|
+
tone?: MetricTone;
|
|
20
|
+
/** Optional ±% trend chip next to the value. Pass `null` (not omitted)
|
|
21
|
+
* to explicitly hide — useful when the comparator base is 0 and the
|
|
22
|
+
* percentage would be meaningless. */
|
|
23
|
+
trend?: number | null;
|
|
24
|
+
/**
|
|
25
|
+
* Goes below the value. Free text. Typical uses: comparator detail
|
|
26
|
+
* ("Tháng trước: 50.000.000 đ"), unit hint ("Hồ sơ"), or short caveat
|
|
27
|
+
* ("Tính theo ngày tạo").
|
|
28
|
+
*/
|
|
29
|
+
caption?: string;
|
|
30
|
+
style?: StyleProp<ViewStyle>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One labeled metric block — the building block of a KPI strip or hero
|
|
35
|
+
* row. Forces the recipe so authors can't drift:
|
|
36
|
+
* - Uppercase muted label (eyeline anchor)
|
|
37
|
+
* - Metric value with tabular nums + tighter tracking at display sizes
|
|
38
|
+
* - Optional inline trend chip (the "is this good?" indicator)
|
|
39
|
+
* - Optional caption below for comparator detail / unit / caveat
|
|
40
|
+
*
|
|
41
|
+
* The recipe matches Mercury's checking dashboard, Stripe's revenue cards,
|
|
42
|
+
* Linear's project metrics. Without enforcing it, dashboards drift into
|
|
43
|
+
* "every metric looks slightly different" — the #1 readability tell on
|
|
44
|
+
* mediocre dashboards.
|
|
45
|
+
*/
|
|
46
|
+
export function KPICard(props: KPICardProps) {
|
|
47
|
+
const { label, value, format, currency, locale, emptyLabel, size = "lg", tone, trend, caption, style } = props;
|
|
48
|
+
return (
|
|
49
|
+
<View style={[styles.container, style]}>
|
|
50
|
+
<Text size="xs" color="muted" transform="uppercase">
|
|
51
|
+
{label}
|
|
52
|
+
</Text>
|
|
53
|
+
<View style={styles.valueRow}>
|
|
54
|
+
<Metric
|
|
55
|
+
value={value}
|
|
56
|
+
format={format}
|
|
57
|
+
currency={currency}
|
|
58
|
+
locale={locale}
|
|
59
|
+
emptyLabel={emptyLabel}
|
|
60
|
+
size={size}
|
|
61
|
+
tone={tone}
|
|
62
|
+
/>
|
|
63
|
+
{trend != null && <TrendChip value={trend} />}
|
|
64
|
+
</View>
|
|
65
|
+
{caption && (
|
|
66
|
+
<Text size="xs" color="muted">
|
|
67
|
+
{caption}
|
|
68
|
+
</Text>
|
|
69
|
+
)}
|
|
70
|
+
</View>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const styles = StyleSheet.create({
|
|
75
|
+
container: { gap: SPACE.xs },
|
|
76
|
+
valueRow: { flexDirection: "row", alignItems: "baseline", gap: SPACE.sm, flexWrap: "wrap" },
|
|
77
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { View, StyleSheet } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { Metric } from "./metric";
|
|
4
|
+
import { SPACE } from "./spacing";
|
|
5
|
+
|
|
6
|
+
interface LegendItemProps {
|
|
7
|
+
/** Square swatch color — should match the segment in the chart this
|
|
8
|
+
* legend annotates. */
|
|
9
|
+
color: string;
|
|
10
|
+
label: string;
|
|
11
|
+
/** Optional count to render after the label. `null` for loading state. */
|
|
12
|
+
value?: number | string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Color swatch + label + optional count. Used as the legend row below a
|
|
17
|
+
* `StackedProgressBar`, a `ChartBar` with categorical colors, or any
|
|
18
|
+
* other chart where consumers need to map color → meaning.
|
|
19
|
+
*
|
|
20
|
+
* Tabular nums on the value (via Metric) keep counts aligned when several
|
|
21
|
+
* legend items sit in a row.
|
|
22
|
+
*/
|
|
23
|
+
export function LegendItem(props: LegendItemProps) {
|
|
24
|
+
return (
|
|
25
|
+
<View style={styles.row}>
|
|
26
|
+
<View style={[styles.swatch, { backgroundColor: props.color }]} />
|
|
27
|
+
<Text size="sm" color="muted">
|
|
28
|
+
{props.label}
|
|
29
|
+
</Text>
|
|
30
|
+
{props.value !== undefined && <Metric value={props.value} size="sm" />}
|
|
31
|
+
</View>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const styles = StyleSheet.create({
|
|
36
|
+
row: {
|
|
37
|
+
flexDirection: "row",
|
|
38
|
+
alignItems: "center",
|
|
39
|
+
gap: SPACE.sm,
|
|
40
|
+
minWidth: 130,
|
|
41
|
+
},
|
|
42
|
+
swatch: {
|
|
43
|
+
width: 8,
|
|
44
|
+
height: 8,
|
|
45
|
+
borderRadius: 2,
|
|
46
|
+
},
|
|
47
|
+
});
|
package/src/metric.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { View, StyleSheet } from "react-native";
|
|
1
|
+
import { View, StyleSheet, type TextStyle } from "react-native";
|
|
2
2
|
import { Text } from "./text";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { useMemo } from "react";
|
|
@@ -8,6 +8,15 @@ export type MetricFormat = "currency" | "number" | "percentage" | "none";
|
|
|
8
8
|
/** Semantic colour of the metric value. `default` inherits the text colour. */
|
|
9
9
|
export type MetricTone = "default" | "warning" | "danger";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Visual scale. `md` is the historical default — matches body display size.
|
|
13
|
+
* `hero` is for the single most important number in a section ("Doanh thu
|
|
14
|
+
* trong tháng: 10.000.000 đ" at the top of a card with smaller supporting
|
|
15
|
+
* metrics below). Mercury / Stripe / Linear all use this hero-supporting
|
|
16
|
+
* hierarchy to give dashboards a reading order.
|
|
17
|
+
*/
|
|
18
|
+
export type MetricSize = "sm" | "md" | "lg" | "hero";
|
|
19
|
+
|
|
11
20
|
export interface MetricProps {
|
|
12
21
|
value: number | string | null | undefined;
|
|
13
22
|
previousValue?: number | string | null | undefined;
|
|
@@ -16,8 +25,26 @@ export interface MetricProps {
|
|
|
16
25
|
locale?: string;
|
|
17
26
|
emptyLabel?: string;
|
|
18
27
|
tone?: MetricTone;
|
|
28
|
+
size?: MetricSize;
|
|
19
29
|
}
|
|
20
30
|
|
|
31
|
+
const SIZE_TO_TEXT_SIZE: Record<MetricSize, "md" | "lg" | "xl" | "xxl"> = {
|
|
32
|
+
sm: "md",
|
|
33
|
+
md: "lg",
|
|
34
|
+
lg: "xl",
|
|
35
|
+
hero: "xxl",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Letter-spacing tightens as font-size grows — "designed" not "inflated".
|
|
39
|
+
// Hero gets the tightest tracking (-1.2) to match the 48px display size's
|
|
40
|
+
// expected condensation.
|
|
41
|
+
const SIZE_TO_LETTER_SPACING: Record<MetricSize, number> = {
|
|
42
|
+
sm: 0,
|
|
43
|
+
md: -0.25,
|
|
44
|
+
lg: -0.5,
|
|
45
|
+
hero: -1.2,
|
|
46
|
+
};
|
|
47
|
+
|
|
21
48
|
const TREND_UP = "↑";
|
|
22
49
|
const TREND_DOWN = "↓";
|
|
23
50
|
const TREND_FLAT = "→";
|
|
@@ -36,6 +63,7 @@ export function Metric(props: MetricProps) {
|
|
|
36
63
|
locale,
|
|
37
64
|
emptyLabel = "-",
|
|
38
65
|
tone = "default",
|
|
66
|
+
size = "md",
|
|
39
67
|
} = props;
|
|
40
68
|
|
|
41
69
|
const displayValue = useMemo(() => {
|
|
@@ -66,9 +94,20 @@ export function Metric(props: MetricProps) {
|
|
|
66
94
|
return (
|
|
67
95
|
<View style={styles.container}>
|
|
68
96
|
<Text
|
|
69
|
-
size=
|
|
70
|
-
weight="
|
|
71
|
-
style={
|
|
97
|
+
size={SIZE_TO_TEXT_SIZE[size]}
|
|
98
|
+
weight="medium"
|
|
99
|
+
style={[
|
|
100
|
+
// Tabular nums prevent the comma/decimal jitter when 1,234 sits
|
|
101
|
+
// next to 7,890 across cards — proportional glyphs misalign every
|
|
102
|
+
// column. Tighter tracking at display sizes reads as "designed",
|
|
103
|
+
// not "inflated body text" (Linear / Stripe / Geist all do this
|
|
104
|
+
// at hero metrics).
|
|
105
|
+
{
|
|
106
|
+
fontVariantNumeric: "tabular-nums",
|
|
107
|
+
letterSpacing: SIZE_TO_LETTER_SPACING[size],
|
|
108
|
+
} as TextStyle,
|
|
109
|
+
tone === "default" ? undefined : { color: TONE_COLOR[tone] },
|
|
110
|
+
]}
|
|
72
111
|
>
|
|
73
112
|
{displayValue}
|
|
74
113
|
</Text>
|
package/src/mime.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Tiny MIME helpers inlined so `@lotics/ui` stays free of @lotics/shared
|
|
2
|
+
// (enforced by primitives_purity.test.ts). The richer MIME taxonomy lives
|
|
3
|
+
// in `@lotics/shared/file_type` for Lotics-coupled code.
|
|
4
|
+
|
|
5
|
+
export function isImageMimeType(mimeType: string): boolean {
|
|
6
|
+
return mimeType.toLowerCase().startsWith("image/");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isVideoMimeType(mimeType: string): boolean {
|
|
10
|
+
return mimeType.toLowerCase().startsWith("video/");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isAudioMimeType(mimeType: string): boolean {
|
|
14
|
+
return mimeType.toLowerCase().startsWith("audio/");
|
|
15
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tracks how many modal/overlay surfaces (dialogs, popovers) are currently open.
|
|
6
|
+
* A counter — not a boolean — so nested overlays compose correctly.
|
|
7
|
+
*/
|
|
8
|
+
let activeCount = 0;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* True while at least one modal dialog or popover is open. Read synchronously
|
|
12
|
+
* by the keyboard shortcut registry to suppress lower-layer page shortcuts so
|
|
13
|
+
* an open overlay does not hijack keystrokes (e.g. native Ctrl+F find).
|
|
14
|
+
*/
|
|
15
|
+
export function isOverlayScopeActive(): boolean {
|
|
16
|
+
return activeCount > 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Imperatively marks an overlay surface as open. Returns a release function
|
|
21
|
+
* that must be called exactly once when the overlay closes. Prefer the
|
|
22
|
+
* `useOverlayScope` hook in React components — this is the testable core.
|
|
23
|
+
*/
|
|
24
|
+
export function pushOverlayScope(): () => void {
|
|
25
|
+
activeCount++;
|
|
26
|
+
let released = false;
|
|
27
|
+
return () => {
|
|
28
|
+
if (released) return;
|
|
29
|
+
released = true;
|
|
30
|
+
activeCount--;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Marks an overlay surface as open while `active` is true. Web-only — no-op on
|
|
36
|
+
* native. Call this from overlay primitives (Dialog, Popover, etc.), not from
|
|
37
|
+
* individual call sites.
|
|
38
|
+
*/
|
|
39
|
+
export function useOverlayScope(active: boolean): void {
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (Platform.OS !== "web" || !active) return;
|
|
42
|
+
return pushOverlayScope();
|
|
43
|
+
}, [active]);
|
|
44
|
+
}
|