@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.
- 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/avatar.web.tsx +102 -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
|
@@ -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
|
+
});
|
package/src/fonts.css
CHANGED
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Inter @font-face for custom-code apps.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* same
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Served from static.lotics.ai (public, CORS-enabled R2) by ABSOLUTE URL, so the
|
|
5
|
+
* same stylesheet resolves on every origin an app is served from — `lotics app
|
|
6
|
+
* dev` on localhost, the API same-origin, and a per-app deploy origin — with no
|
|
7
|
+
* per-origin font routing (no vite /iframe proxy, no app-host worker proxy).
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `lotics app dev` serves the app from localhost, where `/iframe/...` would
|
|
14
|
-
* 404 — the starter vite.config proxies /iframe to the API for development.
|
|
9
|
+
* NOT base64-inlined: ~440 KB of inlined font binary would make this stylesheet
|
|
10
|
+
* render-blocking and delay first paint. Separate cached files with
|
|
11
|
+
* `font-display: swap` block nothing.
|
|
15
12
|
*/
|
|
16
13
|
@font-face {
|
|
17
14
|
font-family: Inter_400Regular;
|
|
18
15
|
font-style: normal;
|
|
19
16
|
font-weight: 400;
|
|
20
|
-
src: url("/
|
|
17
|
+
src: url("https://static.lotics.ai/fonts/Inter_400Regular.woff2") format("woff2");
|
|
21
18
|
font-display: swap;
|
|
22
19
|
}
|
|
23
20
|
|
|
@@ -25,7 +22,7 @@
|
|
|
25
22
|
font-family: Inter_500Medium;
|
|
26
23
|
font-style: normal;
|
|
27
24
|
font-weight: 500;
|
|
28
|
-
src: url("/
|
|
25
|
+
src: url("https://static.lotics.ai/fonts/Inter_500Medium.woff2") format("woff2");
|
|
29
26
|
font-display: swap;
|
|
30
27
|
}
|
|
31
28
|
|
|
@@ -33,6 +30,6 @@
|
|
|
33
30
|
font-family: Inter_600SemiBold;
|
|
34
31
|
font-style: normal;
|
|
35
32
|
font-weight: 600;
|
|
36
|
-
src: url("/
|
|
33
|
+
src: url("https://static.lotics.ai/fonts/Inter_600SemiBold.woff2") format("woff2");
|
|
37
34
|
font-display: swap;
|
|
38
35
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface FormatMoneyOptions {
|
|
2
|
+
locale?: string;
|
|
3
|
+
currency?: string;
|
|
4
|
+
/** Abbreviate for display density: ≥1 tỷ → "1,28 tỷ ₫", ≥1 triệu →
|
|
5
|
+
* "486 tr ₫". For stat strips/cards where the full figure lives in the
|
|
6
|
+
* table below — never for the table itself. */
|
|
7
|
+
compact?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compact vi-style magnitude suffixes: ≥1 tỷ → "1,28 tỷ", ≥1 triệu →
|
|
12
|
+
* "486 tr"; smaller values render in full. Bare numbers only — for money,
|
|
13
|
+
* use `formatMoney` with `compact`.
|
|
14
|
+
*/
|
|
15
|
+
export function formatCompactNumber(value: number, locale = "vi-VN"): string {
|
|
16
|
+
if (Math.abs(value) >= 1_000_000_000) {
|
|
17
|
+
return `${(value / 1_000_000_000).toLocaleString(locale, { maximumFractionDigits: 2 })} tỷ`;
|
|
18
|
+
}
|
|
19
|
+
if (Math.abs(value) >= 1_000_000) {
|
|
20
|
+
return `${Math.round(value / 1_000_000).toLocaleString(locale)} tr`;
|
|
21
|
+
}
|
|
22
|
+
return value.toLocaleString(locale);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* THE money formatter — `Metric` (and through it `KPICard`/`KPIStrip`)
|
|
27
|
+
* delegates here; table cells and captions call it directly. Defaults to
|
|
28
|
+
* the product's home market: `1.234.567 ₫`. Never hand-roll digit grouping
|
|
29
|
+
* or the ₫ suffix.
|
|
30
|
+
*/
|
|
31
|
+
export function formatMoney(value: number, options: FormatMoneyOptions = {}): string {
|
|
32
|
+
const { locale = "vi-VN", currency = "VND", compact = false } = options;
|
|
33
|
+
if (compact && Math.abs(value) >= 1_000_000) {
|
|
34
|
+
const suffixed = formatCompactNumber(value, locale);
|
|
35
|
+
return currency === "VND" ? `${suffixed} ₫` : `${suffixed} ${currency}`;
|
|
36
|
+
}
|
|
37
|
+
return value.toLocaleString(locale, { style: "currency", currency });
|
|
38
|
+
}
|
package/src/heatmap.tsx
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Pressable, StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors, withAlpha } from "./colors";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
|
|
6
|
+
export interface HeatmapAxisItem {
|
|
7
|
+
key: string;
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface HeatmapCell {
|
|
12
|
+
row: string;
|
|
13
|
+
col: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface HeatmapProps {
|
|
17
|
+
rows: HeatmapAxisItem[];
|
|
18
|
+
cols: HeatmapAxisItem[];
|
|
19
|
+
/** values[rowIndex][colIndex] — intensity scales to the matrix max. */
|
|
20
|
+
values: number[][];
|
|
21
|
+
/** The hue. Intensity is alpha within it; zero cells go neutral. */
|
|
22
|
+
color?: string;
|
|
23
|
+
/** The inspected cell — host renders its detail underneath. */
|
|
24
|
+
selected?: HeatmapCell | null;
|
|
25
|
+
/** Press a cell to inspect (press the same cell again to clear). Omit
|
|
26
|
+
* for an informational heatmap. */
|
|
27
|
+
onSelectCell?: (cell: HeatmapCell | null) => void;
|
|
28
|
+
formatValue?: (n: number) => string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const defaultFormat = (n: number): string => n.toLocaleString("en-US");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Density over two dimensions — "when/where does it cluster?" (alarms by
|
|
35
|
+
* site × day, errors by hour, demand by lane × week). One hue; intensity
|
|
36
|
+
* carries the value; zero stays neutral so gaps read as quiet, not as
|
|
37
|
+
* data. Pressing a cell selects it for the host to drill (detail band,
|
|
38
|
+
* filtered list). Pair with a row dimension that matches the screen's
|
|
39
|
+
* other bands so a hot cell points somewhere actionable.
|
|
40
|
+
*/
|
|
41
|
+
export function Heatmap(props: HeatmapProps) {
|
|
42
|
+
const { rows, cols, values, color = colors.blue[600], selected = null, onSelectCell, formatValue = defaultFormat } = props;
|
|
43
|
+
const max = Math.max(1, ...values.flat());
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<View style={styles.container}>
|
|
47
|
+
{rows.map((row, r) => (
|
|
48
|
+
<View key={row.key} style={styles.row}>
|
|
49
|
+
<Text size="xs" color="muted" numberOfLines={1} style={styles.rowLabel}>
|
|
50
|
+
{row.label}
|
|
51
|
+
</Text>
|
|
52
|
+
{cols.map((col, c) => {
|
|
53
|
+
const value = values[r]?.[c] ?? 0;
|
|
54
|
+
const isSelected = selected?.row === row.key && selected?.col === col.key;
|
|
55
|
+
return (
|
|
56
|
+
<Cell
|
|
57
|
+
key={col.key}
|
|
58
|
+
label={`${row.label} · ${col.label}: ${formatValue(value)}`}
|
|
59
|
+
background={value === 0 ? colors.zinc[100] : withAlpha(color, 0.15 + 0.85 * (value / max))}
|
|
60
|
+
selected={isSelected}
|
|
61
|
+
onPress={
|
|
62
|
+
onSelectCell
|
|
63
|
+
? () => onSelectCell(isSelected ? null : { row: row.key, col: col.key })
|
|
64
|
+
: undefined
|
|
65
|
+
}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</View>
|
|
70
|
+
))}
|
|
71
|
+
<View style={styles.row}>
|
|
72
|
+
<View style={styles.rowLabel} />
|
|
73
|
+
{cols.map((col) => (
|
|
74
|
+
<Text key={col.key} size="xs" color="muted" tabular align="center" numberOfLines={1} style={styles.colLabel}>
|
|
75
|
+
{col.label}
|
|
76
|
+
</Text>
|
|
77
|
+
))}
|
|
78
|
+
</View>
|
|
79
|
+
<View style={styles.scale}>
|
|
80
|
+
<Text size="xs" color="muted">Less</Text>
|
|
81
|
+
{[0.15, 0.36, 0.57, 0.78, 1].map((alpha) => (
|
|
82
|
+
<View key={alpha} style={[styles.scaleSwatch, { backgroundColor: withAlpha(color, alpha) }]} />
|
|
83
|
+
))}
|
|
84
|
+
<Text size="xs" color="muted">More</Text>
|
|
85
|
+
</View>
|
|
86
|
+
</View>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface CellProps {
|
|
91
|
+
label: string;
|
|
92
|
+
background: string;
|
|
93
|
+
selected: boolean;
|
|
94
|
+
onPress?: () => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function Cell(props: CellProps) {
|
|
98
|
+
const { label, background, selected, onPress } = props;
|
|
99
|
+
const [hovered, setHovered] = useState(false);
|
|
100
|
+
const base = {
|
|
101
|
+
backgroundColor: background,
|
|
102
|
+
borderColor: selected ? colors.zinc[900] : hovered && onPress ? colors.zinc[400] : "transparent",
|
|
103
|
+
};
|
|
104
|
+
if (!onPress) return <View style={[styles.cell, base]} />;
|
|
105
|
+
return (
|
|
106
|
+
<Pressable
|
|
107
|
+
accessibilityRole="button"
|
|
108
|
+
accessibilityState={{ selected }}
|
|
109
|
+
accessibilityLabel={label}
|
|
110
|
+
onPress={onPress}
|
|
111
|
+
onHoverIn={() => setHovered(true)}
|
|
112
|
+
onHoverOut={() => setHovered(false)}
|
|
113
|
+
style={[styles.cell, base]}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const styles = StyleSheet.create({
|
|
119
|
+
container: {
|
|
120
|
+
gap: 3,
|
|
121
|
+
},
|
|
122
|
+
row: {
|
|
123
|
+
flexDirection: "row",
|
|
124
|
+
alignItems: "center",
|
|
125
|
+
gap: 3,
|
|
126
|
+
},
|
|
127
|
+
rowLabel: {
|
|
128
|
+
width: 88,
|
|
129
|
+
paddingRight: 8,
|
|
130
|
+
},
|
|
131
|
+
cell: {
|
|
132
|
+
flex: 1,
|
|
133
|
+
height: 26,
|
|
134
|
+
borderRadius: 4,
|
|
135
|
+
borderWidth: 2,
|
|
136
|
+
},
|
|
137
|
+
colLabel: {
|
|
138
|
+
flex: 1,
|
|
139
|
+
paddingTop: 2,
|
|
140
|
+
},
|
|
141
|
+
scale: {
|
|
142
|
+
flexDirection: "row",
|
|
143
|
+
alignItems: "center",
|
|
144
|
+
justifyContent: "flex-end",
|
|
145
|
+
gap: 4,
|
|
146
|
+
paddingTop: 8,
|
|
147
|
+
},
|
|
148
|
+
scaleSwatch: {
|
|
149
|
+
width: 14,
|
|
150
|
+
height: 10,
|
|
151
|
+
borderRadius: 3,
|
|
152
|
+
},
|
|
153
|
+
});
|
package/src/icon.tsx
CHANGED
|
@@ -92,6 +92,7 @@ import History from "lucide-react-native/dist/esm/icons/history";
|
|
|
92
92
|
import House from "lucide-react-native/dist/esm/icons/house";
|
|
93
93
|
import Image from "lucide-react-native/dist/esm/icons/image";
|
|
94
94
|
import Inbox from "lucide-react-native/dist/esm/icons/inbox";
|
|
95
|
+
import Info from "lucide-react-native/dist/esm/icons/info";
|
|
95
96
|
import Italic from "lucide-react-native/dist/esm/icons/italic";
|
|
96
97
|
import Keyboard from "lucide-react-native/dist/esm/icons/keyboard";
|
|
97
98
|
import Languages from "lucide-react-native/dist/esm/icons/languages";
|
|
@@ -283,6 +284,7 @@ const iconComponents = {
|
|
|
283
284
|
house: House,
|
|
284
285
|
image: Image,
|
|
285
286
|
inbox: Inbox,
|
|
287
|
+
info: Info,
|
|
286
288
|
italic: Italic,
|
|
287
289
|
keyboard: Keyboard,
|
|
288
290
|
languages: Languages,
|
package/src/icon_button.tsx
CHANGED
|
@@ -10,6 +10,10 @@ interface IconButtonBase {
|
|
|
10
10
|
testID?: string;
|
|
11
11
|
icon: IconName;
|
|
12
12
|
color?: "none" | "secondary" | "white";
|
|
13
|
+
/** `md` (28px, 18px glyph) for toolbars; `sm` (24px, 14px glyph) for
|
|
14
|
+
* inline affordances sitting next to body/sm text — e.g. the ⓘ in a
|
|
15
|
+
* CardHeader. Both keep a 40px touch target via hitSlop. */
|
|
16
|
+
size?: "md" | "sm";
|
|
13
17
|
iconColor?: string;
|
|
14
18
|
tooltipSide?: TooltipSide;
|
|
15
19
|
onPress?: (event: GestureResponderEvent) => void;
|
|
@@ -34,6 +38,7 @@ export function IconButton(props: IconButtonProps) {
|
|
|
34
38
|
iconColor,
|
|
35
39
|
onPress,
|
|
36
40
|
color = "none",
|
|
41
|
+
size = "md",
|
|
37
42
|
tooltip,
|
|
38
43
|
tooltipSide,
|
|
39
44
|
accessibilityLabel,
|
|
@@ -56,12 +61,15 @@ export function IconButton(props: IconButtonProps) {
|
|
|
56
61
|
tooltipSide={tooltipSide}
|
|
57
62
|
accessibilityRole="button"
|
|
58
63
|
accessibilityLabel={accessibilityLabel ?? tooltip}
|
|
59
|
-
style={[styles.button, styles[color], disabled && styles.disabled, style]}
|
|
64
|
+
style={[styles.button, styles[size], styles[color], disabled && styles.disabled, style]}
|
|
60
65
|
onPress={handlePress}
|
|
61
66
|
disabled={disabled}
|
|
67
|
+
// 40px touch target regardless of visual size (28 + 2×6 / 24 + 2×8)
|
|
68
|
+
// without shifting surrounding layouts.
|
|
69
|
+
hitSlop={size === "sm" ? 8 : 6}
|
|
62
70
|
>
|
|
63
71
|
<Icon
|
|
64
|
-
size={18}
|
|
72
|
+
size={size === "sm" ? 14 : 18}
|
|
65
73
|
name={icon}
|
|
66
74
|
color={iconColor || (color === "white" ? colors.white : colors.zinc[700])}
|
|
67
75
|
/>
|
|
@@ -76,9 +84,15 @@ const styles = StyleSheet.create({
|
|
|
76
84
|
alignItems: "center",
|
|
77
85
|
justifyContent: "center",
|
|
78
86
|
borderRadius: 999,
|
|
87
|
+
},
|
|
88
|
+
md: {
|
|
79
89
|
width: 28,
|
|
80
90
|
height: 28,
|
|
81
91
|
},
|
|
92
|
+
sm: {
|
|
93
|
+
width: 24,
|
|
94
|
+
height: 24,
|
|
95
|
+
},
|
|
82
96
|
disabled: {
|
|
83
97
|
opacity: 0.3,
|
|
84
98
|
},
|
package/src/index.css
CHANGED
|
@@ -353,6 +353,7 @@ html {
|
|
|
353
353
|
- Frontend: /fonts/Inter_*.woff2 (served from public/)
|
|
354
354
|
- Extension: fonts/Inter_*.woff2 (relative, in dist/fonts/)
|
|
355
355
|
See frontend/public/fonts/ and browser_extension/public/fonts.css.
|
|
356
|
-
Contexts without a stable font-file path (custom-code apps served
|
|
357
|
-
|
|
358
|
-
|
|
356
|
+
Contexts without a stable font-file path (custom-code apps served from a
|
|
357
|
+
per-app deploy origin) can `import "@lotics/ui/fonts.css"` — it loads Inter
|
|
358
|
+
from static.lotics.ai by absolute, CORS-enabled URL, so it works from any
|
|
359
|
+
origin. See src/fonts.css. */
|
package/src/info_popover.tsx
CHANGED
|
@@ -17,21 +17,19 @@ export interface InfoPopoverProps {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
20
|
+
* The ⓘ button that opens a popover explaining something — a metric, a
|
|
21
21
|
* field, a setting. The middle ground between Tooltip (a short hover label)
|
|
22
22
|
* and composing Popover directly (rich, interactive content): a labeled,
|
|
23
23
|
* keyboard-accessible help affordance for a sentence or two of text.
|
|
24
|
+
* Sized to sit inline next to sm/body text (a CardHeaderTitle, a form
|
|
25
|
+
* label) without inflating the line.
|
|
24
26
|
*/
|
|
25
27
|
export function InfoPopover(props: InfoPopoverProps) {
|
|
26
28
|
const { text, accessibilityLabel = "More information", side = "bottom", align = "end" } = props;
|
|
27
29
|
return (
|
|
28
30
|
<Popover side={side} align={align}>
|
|
29
31
|
<PopoverTrigger>
|
|
30
|
-
<IconButton
|
|
31
|
-
icon="message-circle-question-mark"
|
|
32
|
-
iconColor={colors.zinc[500]}
|
|
33
|
-
accessibilityLabel={accessibilityLabel}
|
|
34
|
-
/>
|
|
32
|
+
<IconButton icon="info" size="sm" iconColor={colors.zinc[500]} accessibilityLabel={accessibilityLabel} />
|
|
35
33
|
</PopoverTrigger>
|
|
36
34
|
<PopoverContent style={styles.content} disableBodyScroll>
|
|
37
35
|
<Text size="sm" color="muted">
|
package/src/kpi_card.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { View, StyleSheet, type ViewStyle, type StyleProp } from "react-native";
|
|
|
2
2
|
import { Text } from "./text";
|
|
3
3
|
import { Metric, type MetricFormat, type MetricSize, type MetricTone } from "./metric";
|
|
4
4
|
import { TrendChip } from "./trend_chip";
|
|
5
|
+
import { InfoPopover } from "./info_popover";
|
|
5
6
|
import { SPACE } from "./spacing";
|
|
6
7
|
|
|
7
8
|
interface KPICardProps {
|
|
@@ -13,6 +14,8 @@ interface KPICardProps {
|
|
|
13
14
|
currency?: string;
|
|
14
15
|
locale?: string;
|
|
15
16
|
emptyLabel?: string;
|
|
17
|
+
/** Abbreviate large values (≥1 triệu) — see Metric.compact. */
|
|
18
|
+
compact?: boolean;
|
|
16
19
|
/** Numeric size hint. Default `lg`; pass `hero` for the dominant
|
|
17
20
|
* metric in a card; `md` for supporting metrics in a horizontal strip. */
|
|
18
21
|
size?: MetricSize;
|
|
@@ -23,10 +26,12 @@ interface KPICardProps {
|
|
|
23
26
|
trend?: number | null;
|
|
24
27
|
/**
|
|
25
28
|
* Goes below the value. Free text. Typical uses: comparator detail
|
|
26
|
-
* ("
|
|
27
|
-
* ("Tính theo ngày tạo").
|
|
29
|
+
* ("Last month: 50,000,000 ₫"), unit hint, or short caveat.
|
|
28
30
|
*/
|
|
29
31
|
caption?: string;
|
|
32
|
+
/** What this metric means / how it's computed — renders the ⓘ next to
|
|
33
|
+
* the label, opening the explanation in a popover. */
|
|
34
|
+
info?: string;
|
|
30
35
|
style?: StyleProp<ViewStyle>;
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -44,12 +49,15 @@ interface KPICardProps {
|
|
|
44
49
|
* mediocre dashboards.
|
|
45
50
|
*/
|
|
46
51
|
export function KPICard(props: KPICardProps) {
|
|
47
|
-
const { label, value, format, currency, locale, emptyLabel, size = "lg", tone, trend, caption, style } = props;
|
|
52
|
+
const { label, value, format, currency, locale, emptyLabel, compact, size = "lg", tone, trend, caption, info, style } = props;
|
|
48
53
|
return (
|
|
49
54
|
<View style={[styles.container, style]}>
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
<View style={styles.labelRow}>
|
|
56
|
+
<Text size="xs" color="muted" transform="uppercase">
|
|
57
|
+
{label}
|
|
58
|
+
</Text>
|
|
59
|
+
{info ? <InfoPopover text={info} accessibilityLabel={`About ${label}`} /> : null}
|
|
60
|
+
</View>
|
|
53
61
|
<View style={styles.valueRow}>
|
|
54
62
|
<Metric
|
|
55
63
|
value={value}
|
|
@@ -57,6 +65,7 @@ export function KPICard(props: KPICardProps) {
|
|
|
57
65
|
currency={currency}
|
|
58
66
|
locale={locale}
|
|
59
67
|
emptyLabel={emptyLabel}
|
|
68
|
+
compact={compact}
|
|
60
69
|
size={size}
|
|
61
70
|
tone={tone}
|
|
62
71
|
/>
|
|
@@ -73,5 +82,9 @@ export function KPICard(props: KPICardProps) {
|
|
|
73
82
|
|
|
74
83
|
const styles = StyleSheet.create({
|
|
75
84
|
container: { gap: SPACE.xs },
|
|
85
|
+
// Reserve the inline ⓘ button's height (sm IconButton = 24) on EVERY label row
|
|
86
|
+
// so columns with and without `info` center their labels — and thus their value
|
|
87
|
+
// rows — on the same baseline. Without this the no-info columns sit 4px higher.
|
|
88
|
+
labelRow: { flexDirection: "row", alignItems: "center", gap: 2, minHeight: 24 },
|
|
76
89
|
valueRow: { flexDirection: "row", alignItems: "baseline", gap: SPACE.sm, flexWrap: "wrap" },
|
|
77
90
|
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { Card } from "./card";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { KPICard } from "./kpi_card";
|
|
5
|
+
import type { MetricFormat, MetricSize, MetricTone } from "./metric";
|
|
6
|
+
|
|
7
|
+
export interface KPIStripItem {
|
|
8
|
+
/** Short uppercase label above the value. */
|
|
9
|
+
label: string;
|
|
10
|
+
value: number | string | null | undefined;
|
|
11
|
+
format?: MetricFormat;
|
|
12
|
+
currency?: string;
|
|
13
|
+
locale?: string;
|
|
14
|
+
emptyLabel?: string;
|
|
15
|
+
tone?: MetricTone;
|
|
16
|
+
/** Abbreviate large values (≥1 triệu → "486 tr") — strip columns are
|
|
17
|
+
* narrow; the full figure belongs in the content below. */
|
|
18
|
+
compact?: boolean;
|
|
19
|
+
/** Optional ±% trend chip — the "is this good?" indicator. */
|
|
20
|
+
trend?: number | null;
|
|
21
|
+
/** Optional comparator/unit line below the value. */
|
|
22
|
+
caption?: string;
|
|
23
|
+
/** What this metric means / how it's computed — the ⓘ next to the label
|
|
24
|
+
* opens it in a popover. KPI strips are INFORMATIONAL: filtering belongs
|
|
25
|
+
* to the tabs/chips below, never to the strip. */
|
|
26
|
+
info?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface KPIStripProps {
|
|
30
|
+
items: KPIStripItem[];
|
|
31
|
+
/** Value scale, uniform across every column by design. Default `lg` —
|
|
32
|
+
* the big-number dashboard register. */
|
|
33
|
+
size?: MetricSize;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* THE stat band at the top of a screen — one Card, hairline-divided columns
|
|
38
|
+
* of KPICard with a uniform value scale. Screens must not hand-compose stat
|
|
39
|
+
* strips: the same four-numbers-in-a-row should look identical on a
|
|
40
|
+
* dashboard, an inventory register, or a payroll page. Wraps on narrow
|
|
41
|
+
* screens (columns keep a 180px floor).
|
|
42
|
+
*/
|
|
43
|
+
export function KPIStrip(props: KPIStripProps) {
|
|
44
|
+
const { items, size = "lg" } = props;
|
|
45
|
+
return (
|
|
46
|
+
<Card style={styles.card}>
|
|
47
|
+
<View style={styles.row}>
|
|
48
|
+
{items.map((item, i) => (
|
|
49
|
+
<View key={item.label} style={[styles.cell, i > 0 && styles.divided]}>
|
|
50
|
+
<KPICard
|
|
51
|
+
label={item.label}
|
|
52
|
+
value={item.value}
|
|
53
|
+
format={item.format}
|
|
54
|
+
currency={item.currency}
|
|
55
|
+
locale={item.locale}
|
|
56
|
+
emptyLabel={item.emptyLabel}
|
|
57
|
+
compact={item.compact}
|
|
58
|
+
tone={item.tone}
|
|
59
|
+
trend={item.trend}
|
|
60
|
+
caption={item.caption}
|
|
61
|
+
info={item.info}
|
|
62
|
+
size={size}
|
|
63
|
+
/>
|
|
64
|
+
</View>
|
|
65
|
+
))}
|
|
66
|
+
</View>
|
|
67
|
+
</Card>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const styles = StyleSheet.create({
|
|
72
|
+
card: {
|
|
73
|
+
padding: 0,
|
|
74
|
+
},
|
|
75
|
+
row: {
|
|
76
|
+
flexDirection: "row",
|
|
77
|
+
flexWrap: "wrap",
|
|
78
|
+
},
|
|
79
|
+
cell: {
|
|
80
|
+
flexGrow: 1,
|
|
81
|
+
flexBasis: 180,
|
|
82
|
+
paddingVertical: 16,
|
|
83
|
+
paddingHorizontal: 20,
|
|
84
|
+
},
|
|
85
|
+
divided: {
|
|
86
|
+
borderLeftWidth: 1,
|
|
87
|
+
borderLeftColor: colors.zinc[100],
|
|
88
|
+
},
|
|
89
|
+
});
|