@lotics/ui 1.6.1 → 1.8.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 +18 -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/kpi_card.tsx +77 -0
- package/src/legend_item.tsx +47 -0
- package/src/metric.tsx +43 -4
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -10,6 +10,18 @@
|
|
|
10
10
|
"./line_chart": "./src/line_chart.tsx",
|
|
11
11
|
"./pie_chart": "./src/pie_chart.tsx",
|
|
12
12
|
"./metric": "./src/metric.tsx",
|
|
13
|
+
"./sparkline": "./src/sparkline.tsx",
|
|
14
|
+
"./trend_chip": "./src/trend_chip.tsx",
|
|
15
|
+
"./section_card": "./src/section_card.tsx",
|
|
16
|
+
"./kpi_card": "./src/kpi_card.tsx",
|
|
17
|
+
"./alert_row": "./src/alert_row.tsx",
|
|
18
|
+
"./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
|
|
19
|
+
"./legend_item": "./src/legend_item.tsx",
|
|
20
|
+
"./trend_footer": "./src/trend_footer.tsx",
|
|
21
|
+
"./chart_area": "./src/chart_area.tsx",
|
|
22
|
+
"./chart_bar": "./src/chart_bar.tsx",
|
|
23
|
+
"./spacing": "./src/spacing.ts",
|
|
24
|
+
"./theme": "./src/theme.tsx",
|
|
13
25
|
"./progress_bar": "./src/progress_bar.tsx",
|
|
14
26
|
"./form_date_picker": "./src/form_date_picker.tsx",
|
|
15
27
|
"./form_time_picker": "./src/form_time_picker.tsx",
|
|
@@ -25,6 +37,7 @@
|
|
|
25
37
|
"./portal": "./src/portal.tsx",
|
|
26
38
|
"./popover_nav": "./src/popover_nav.tsx",
|
|
27
39
|
"./popover": "./src/popover.tsx",
|
|
40
|
+
"./overlay_scope": "./src/overlay_scope.ts",
|
|
28
41
|
"./switcher": "./src/switcher.tsx",
|
|
29
42
|
"./menu_button": "./src/menu_button.tsx",
|
|
30
43
|
"./menu_list_item": "./src/menu_list_item.tsx",
|
|
@@ -130,13 +143,15 @@
|
|
|
130
143
|
"lucide-react": ">=0.460.0",
|
|
131
144
|
"react-native-svg": ">=15.0.0",
|
|
132
145
|
"expo-image": ">=3.0.0",
|
|
133
|
-
"@react-native-picker/picker": ">=2.0.0"
|
|
146
|
+
"@react-native-picker/picker": ">=2.0.0",
|
|
147
|
+
"recharts": ">=3.0.0"
|
|
134
148
|
},
|
|
135
149
|
"peerDependenciesMeta": {
|
|
136
150
|
"expo-image": { "optional": true },
|
|
137
151
|
"@react-native-picker/picker": { "optional": true },
|
|
138
152
|
"lucide-react-native": { "optional": true },
|
|
139
|
-
"react-native-svg": { "optional": true }
|
|
153
|
+
"react-native-svg": { "optional": true },
|
|
154
|
+
"recharts": { "optional": true }
|
|
140
155
|
},
|
|
141
156
|
"scripts": {
|
|
142
157
|
"typecheck": "tsgo --noEmit",
|
package/src/alert.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { createRoot, Root } from "react-dom/client";
|
|
|
3
3
|
import "./alert.css";
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { Button, ButtonColor } from "./button";
|
|
6
|
+
import { useOverlayScope } from "./overlay_scope";
|
|
6
7
|
|
|
7
8
|
export type AlertButtonStyle = "default" | "cancel" | "destructive";
|
|
8
9
|
|
|
@@ -81,6 +82,8 @@ class Alert {
|
|
|
81
82
|
const AlertComponent = () => {
|
|
82
83
|
const [visible, setVisible] = useState(true);
|
|
83
84
|
|
|
85
|
+
useOverlayScope(visible);
|
|
86
|
+
|
|
84
87
|
const handleDismiss = useCallback(() => {
|
|
85
88
|
if (alertOptions.cancelable !== false) {
|
|
86
89
|
setVisible(false);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Metric, type MetricSize, type MetricTone } from "./metric";
|
|
5
|
+
import { colors } from "./colors";
|
|
6
|
+
import { SPACE } from "./spacing";
|
|
7
|
+
|
|
8
|
+
interface AlertRowProps {
|
|
9
|
+
/** Leading icon (lucide-react `<AlertCircle />`, `<CheckCircle2 />`, etc.).
|
|
10
|
+
* Sized 18px in the host. Caller controls color so different severities
|
|
11
|
+
* can use different palettes. */
|
|
12
|
+
icon: ReactNode;
|
|
13
|
+
label: string;
|
|
14
|
+
/** The count or quantity. `null` while loading → renders as `emptyLabel`. */
|
|
15
|
+
count: number | string | null;
|
|
16
|
+
/** Small text below the count — unit ("Hồ sơ"), amount caption
|
|
17
|
+
* ("12.000.000 đ"), or short hint. */
|
|
18
|
+
hint?: string;
|
|
19
|
+
/** Number size. Default `lg` keeps the row scannable; `md` for denser
|
|
20
|
+
* lists where many rows compete. */
|
|
21
|
+
size?: MetricSize;
|
|
22
|
+
/** Auto-set: non-zero count = danger (red), zero = default. Override for
|
|
23
|
+
* status semantics (e.g., when down is good). */
|
|
24
|
+
tone?: MetricTone;
|
|
25
|
+
/** Suppress the bottom hairline. Set on the last row of a list. */
|
|
26
|
+
last?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One row of a prioritized action list — icon + label on the left, count
|
|
31
|
+
* + hint on the right. The Mercury / Linear "needs attention" pattern.
|
|
32
|
+
*
|
|
33
|
+
* Layout enforces two-column rhythm: left side flex-grows with the label,
|
|
34
|
+
* right side is fixed-width and right-aligned. Without enforcement, rows
|
|
35
|
+
* drift into "label centered, count floating left of right edge" which
|
|
36
|
+
* makes the list unscannable.
|
|
37
|
+
*/
|
|
38
|
+
export function AlertRow(props: AlertRowProps) {
|
|
39
|
+
const { icon, label, count, hint, size = "lg", tone, last } = props;
|
|
40
|
+
const hasIssue = typeof count === "number" && count > 0;
|
|
41
|
+
const resolvedTone: MetricTone = tone ?? (hasIssue ? "danger" : "default");
|
|
42
|
+
return (
|
|
43
|
+
<View style={[styles.row, !last && styles.divider]}>
|
|
44
|
+
<View style={styles.leftCol}>
|
|
45
|
+
{icon}
|
|
46
|
+
<Text size="sm">{label}</Text>
|
|
47
|
+
</View>
|
|
48
|
+
<View style={styles.rightCol}>
|
|
49
|
+
<Metric value={count} size={size} tone={resolvedTone} />
|
|
50
|
+
{hint && (
|
|
51
|
+
<Text size="xs" color="muted">
|
|
52
|
+
{hint}
|
|
53
|
+
</Text>
|
|
54
|
+
)}
|
|
55
|
+
</View>
|
|
56
|
+
</View>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const styles = StyleSheet.create({
|
|
61
|
+
row: {
|
|
62
|
+
flexDirection: "row",
|
|
63
|
+
alignItems: "center",
|
|
64
|
+
justifyContent: "space-between",
|
|
65
|
+
paddingVertical: SPACE.md,
|
|
66
|
+
},
|
|
67
|
+
divider: {
|
|
68
|
+
borderBottomWidth: 1,
|
|
69
|
+
borderBottomColor: colors.zinc[100],
|
|
70
|
+
},
|
|
71
|
+
leftCol: {
|
|
72
|
+
flexDirection: "row",
|
|
73
|
+
alignItems: "center",
|
|
74
|
+
gap: SPACE.md,
|
|
75
|
+
flex: 1,
|
|
76
|
+
},
|
|
77
|
+
rightCol: {
|
|
78
|
+
alignItems: "flex-end",
|
|
79
|
+
minWidth: 100,
|
|
80
|
+
},
|
|
81
|
+
});
|
package/src/card.tsx
CHANGED
|
@@ -40,6 +40,20 @@ const styles = StyleSheet.create({
|
|
|
40
40
|
padding: 20,
|
|
41
41
|
borderRadius: 16,
|
|
42
42
|
backgroundColor: colors.background,
|
|
43
|
+
// 1px hairline + 2-layer shadow: the research-validated default across
|
|
44
|
+
// shadcn/ui, Geist, Tailwind v4. Without either, cards on a near-white
|
|
45
|
+
// page bg float without rhythm. Border anchors; shadow lifts. The shadow
|
|
46
|
+
// uses warm-neutral tint (38,38,38), not pure black — premium signal vs
|
|
47
|
+
// cheap-feeling pure-black shadows.
|
|
48
|
+
borderWidth: 1,
|
|
49
|
+
borderColor: colors.zinc["200"],
|
|
50
|
+
// Slightly more visible than shadcn's `shadow-sm` — chosen because our
|
|
51
|
+
// page bg is `colors.background` (off-white), making `shadow-sm` nearly
|
|
52
|
+
// invisible. Stripe / Linear use this stronger value on similar bg.
|
|
53
|
+
...({
|
|
54
|
+
boxShadow:
|
|
55
|
+
"0 1px 2px 0 rgba(38,38,38,0.06), 0 4px 12px -2px rgba(38,38,38,0.06)",
|
|
56
|
+
} as ViewStyle),
|
|
43
57
|
},
|
|
44
58
|
hovered: {
|
|
45
59
|
outlineColor: colors.zinc["900"],
|
package/src/cell_date_format.ts
CHANGED
|
@@ -26,11 +26,22 @@ export function formatDateValue(
|
|
|
26
26
|
const { format = "date", dateStyle = "short", timeStyle = "short" } = options;
|
|
27
27
|
const includeTime = format === "datetime";
|
|
28
28
|
|
|
29
|
+
// Intl's `dateStyle: "short"` yields a 2-digit year in many locales (en-US: "5/22/26",
|
|
30
|
+
// vi-VN: "22/05/26"); for data cells we want a compact numeric format with the full year.
|
|
31
|
+
// When we override to explicit date parts, the time parts must also be explicit — Intl
|
|
32
|
+
// forbids mixing `dateStyle`/`timeStyle` with `year`/`month`/`day`/`hour`/`minute`/etc.
|
|
33
|
+
const intlOptions: Intl.DateTimeFormatOptions =
|
|
34
|
+
dateStyle === "short"
|
|
35
|
+
? {
|
|
36
|
+
year: "numeric",
|
|
37
|
+
month: "numeric",
|
|
38
|
+
day: "numeric",
|
|
39
|
+
...(includeTime ? { hour: "numeric", minute: "numeric" } : {}),
|
|
40
|
+
}
|
|
41
|
+
: { dateStyle, ...(includeTime ? { timeStyle } : {}) };
|
|
42
|
+
|
|
29
43
|
try {
|
|
30
|
-
return new Intl.DateTimeFormat(
|
|
31
|
-
locale,
|
|
32
|
-
includeTime ? { dateStyle, timeStyle } : { dateStyle },
|
|
33
|
-
).format(date);
|
|
44
|
+
return new Intl.DateTimeFormat(locale, intlOptions).format(date);
|
|
34
45
|
} catch {
|
|
35
46
|
return includeTime ? date.toISOString() : value;
|
|
36
47
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
Area,
|
|
5
|
+
AreaChart,
|
|
6
|
+
CartesianGrid,
|
|
7
|
+
ResponsiveContainer,
|
|
8
|
+
Tooltip,
|
|
9
|
+
XAxis,
|
|
10
|
+
YAxis,
|
|
11
|
+
} from "recharts";
|
|
12
|
+
import { colors } from "./colors";
|
|
13
|
+
import { useLoticsTheme } from "./theme";
|
|
14
|
+
import { chartAxisTickStyle, chartTooltipLabelStyle, chartTooltipStyle } from "./chart_internals";
|
|
15
|
+
|
|
16
|
+
interface ChartAreaProps<T> {
|
|
17
|
+
data: T[];
|
|
18
|
+
/** Property to use as the X-axis category (e.g. month label). */
|
|
19
|
+
xKey: keyof T & string;
|
|
20
|
+
/** Property to use as the Y-axis value. */
|
|
21
|
+
yKey: keyof T & string;
|
|
22
|
+
/** Pixel height. Default 200. Charts shorter than ~120 lose readability;
|
|
23
|
+
* taller than ~280 dominate the card. */
|
|
24
|
+
height?: number;
|
|
25
|
+
/** Override the theme accent for this chart instance. */
|
|
26
|
+
color?: string;
|
|
27
|
+
/** Format the Y value in tooltips. Defaults to `Intl.NumberFormat` for
|
|
28
|
+
* numbers, identity for strings. */
|
|
29
|
+
formatValue?: (value: number) => string;
|
|
30
|
+
/** Label shown next to the value in the tooltip. Default `Value`. */
|
|
31
|
+
valueLabel?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Shadcn-style area chart — gradient fill, dashed horizontal-only
|
|
36
|
+
* gridlines, clean axes (no axisLine, no tickLine), tooltip with the
|
|
37
|
+
* standard chart tooltip chrome.
|
|
38
|
+
*
|
|
39
|
+
* Defaults pulled from the `useLoticsTheme()` accent so consumers don't
|
|
40
|
+
* need to think about color choice. Override via the `color` prop for
|
|
41
|
+
* one-off charts that need a different hue.
|
|
42
|
+
*
|
|
43
|
+
* Why a wrapper instead of just exposing Recharts to consumers: the
|
|
44
|
+
* "shadcn pattern" is a dozen non-obvious Recharts props (axisLine={false},
|
|
45
|
+
* tickLine={false}, monotone interpolation, gradient stops at 5% and 95%,
|
|
46
|
+
* stroke-dasharray "3 3" on grid, …). Without the wrapper, every
|
|
47
|
+
* consumer has to remember the full recipe; with it, they pass data and
|
|
48
|
+
* get the polish.
|
|
49
|
+
*/
|
|
50
|
+
export function ChartArea<T extends Record<string, unknown>>(props: ChartAreaProps<T>) {
|
|
51
|
+
const { data, xKey, yKey, height = 200, color, formatValue, valueLabel = "Value" } = props;
|
|
52
|
+
const theme = useLoticsTheme();
|
|
53
|
+
const fill = color ?? theme.accent;
|
|
54
|
+
// `useId` guarantees the gradient `id` is unique even when multiple
|
|
55
|
+
// ChartArea instances render on the same page — without it, two charts
|
|
56
|
+
// would share the same `url(#revenueFill)` and the second would lose
|
|
57
|
+
// its gradient.
|
|
58
|
+
const gradientId = `chartArea-${useId()}`;
|
|
59
|
+
return (
|
|
60
|
+
<View style={{ height }}>
|
|
61
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
62
|
+
<AreaChart data={data} margin={{ top: 12, right: 8, left: 8, bottom: 0 }}>
|
|
63
|
+
<defs>
|
|
64
|
+
{/* 3-stop gradient: deeper top (55%), accelerated mid-fade,
|
|
65
|
+
clean bottom. Single 5%→95% linear stop reads as "tinted",
|
|
66
|
+
3-stop with the middle inflection at 50%/18% reads as
|
|
67
|
+
"designed". Stripe / Mercury use the multi-stop shape; the
|
|
68
|
+
eye picks up the non-linear falloff as intentional. */}
|
|
69
|
+
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
70
|
+
<stop offset="0%" stopColor={fill} stopOpacity={0.55} />
|
|
71
|
+
<stop offset="50%" stopColor={fill} stopOpacity={0.18} />
|
|
72
|
+
<stop offset="100%" stopColor={fill} stopOpacity={0} />
|
|
73
|
+
</linearGradient>
|
|
74
|
+
</defs>
|
|
75
|
+
<CartesianGrid vertical={false} stroke={colors.zinc[200]} strokeDasharray="3 3" />
|
|
76
|
+
<XAxis
|
|
77
|
+
dataKey={xKey as string}
|
|
78
|
+
axisLine={false}
|
|
79
|
+
tickLine={false}
|
|
80
|
+
tickMargin={10}
|
|
81
|
+
tick={chartAxisTickStyle}
|
|
82
|
+
/>
|
|
83
|
+
<YAxis hide />
|
|
84
|
+
<Tooltip
|
|
85
|
+
cursor={{ stroke: colors.zinc[300], strokeDasharray: "3 3" }}
|
|
86
|
+
contentStyle={chartTooltipStyle}
|
|
87
|
+
labelStyle={chartTooltipLabelStyle}
|
|
88
|
+
formatter={(val: unknown) => {
|
|
89
|
+
if (typeof val !== "number") return [String(val), valueLabel];
|
|
90
|
+
return [formatValue ? formatValue(val) : val.toLocaleString(), valueLabel];
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
<Area
|
|
94
|
+
type="monotone"
|
|
95
|
+
dataKey={yKey as string}
|
|
96
|
+
stroke={fill}
|
|
97
|
+
strokeWidth={2.5}
|
|
98
|
+
fill={`url(#${gradientId})`}
|
|
99
|
+
animationDuration={600}
|
|
100
|
+
/>
|
|
101
|
+
</AreaChart>
|
|
102
|
+
</ResponsiveContainer>
|
|
103
|
+
</View>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { View } from "react-native";
|
|
2
|
+
import {
|
|
3
|
+
Bar,
|
|
4
|
+
BarChart,
|
|
5
|
+
CartesianGrid,
|
|
6
|
+
Cell,
|
|
7
|
+
ResponsiveContainer,
|
|
8
|
+
Tooltip,
|
|
9
|
+
XAxis,
|
|
10
|
+
YAxis,
|
|
11
|
+
} from "recharts";
|
|
12
|
+
import { colors } from "./colors";
|
|
13
|
+
import { useLoticsTheme } from "./theme";
|
|
14
|
+
import {
|
|
15
|
+
chartAxisTickStyle,
|
|
16
|
+
chartTooltipLabelStyle,
|
|
17
|
+
chartTooltipStyle,
|
|
18
|
+
chartYAxisCategoryTickStyle,
|
|
19
|
+
} from "./chart_internals";
|
|
20
|
+
|
|
21
|
+
interface ChartBarProps<T> {
|
|
22
|
+
data: T[];
|
|
23
|
+
/** Property to use as the category axis. */
|
|
24
|
+
categoryKey: keyof T & string;
|
|
25
|
+
/** Property to use as the value axis. */
|
|
26
|
+
valueKey: keyof T & string;
|
|
27
|
+
/** Per-row fill color. Optional — when omitted, all bars use the theme
|
|
28
|
+
* accent. Set this when each category needs its own color (funnel
|
|
29
|
+
* stages, status breakdowns, etc.). */
|
|
30
|
+
fillKey?: keyof T & string;
|
|
31
|
+
/** Orientation. Default `vertical` (category on X, value on Y). Use
|
|
32
|
+
* `horizontal` for "rank by value" lists where category names are
|
|
33
|
+
* long (member names, account labels). */
|
|
34
|
+
orientation?: "vertical" | "horizontal";
|
|
35
|
+
/** Pixel height. Default 220 vertical / row-count × 56 horizontal.
|
|
36
|
+
* Pass explicit value for fixed-height layouts. */
|
|
37
|
+
height?: number;
|
|
38
|
+
/** Override the theme accent for this chart instance. */
|
|
39
|
+
color?: string;
|
|
40
|
+
/** Format the value in tooltips + value labels. */
|
|
41
|
+
formatValue?: (value: number) => string;
|
|
42
|
+
/** Label shown next to the value in the tooltip. Default `Value`. */
|
|
43
|
+
valueLabel?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Shadcn-style bar chart. Vertical (categories on X axis) is the default;
|
|
48
|
+
* pass `orientation="horizontal"` for rank-by-value lists where labels are
|
|
49
|
+
* long.
|
|
50
|
+
*
|
|
51
|
+
* Vertical: rounded TOP corners only (radius 6) — the shadcn pattern.
|
|
52
|
+
* Horizontal: rounded RIGHT corners only.
|
|
53
|
+
*
|
|
54
|
+
* Per-bar colors via `fillKey`: each data row carries its own color. Use
|
|
55
|
+
* for funnel/category breakdowns. Omit for single-hue bar charts (the
|
|
56
|
+
* theme accent fills every bar).
|
|
57
|
+
*/
|
|
58
|
+
export function ChartBar<T extends Record<string, unknown>>(props: ChartBarProps<T>) {
|
|
59
|
+
const {
|
|
60
|
+
data,
|
|
61
|
+
categoryKey,
|
|
62
|
+
valueKey,
|
|
63
|
+
fillKey,
|
|
64
|
+
orientation = "vertical",
|
|
65
|
+
height,
|
|
66
|
+
color,
|
|
67
|
+
formatValue,
|
|
68
|
+
valueLabel = "Value",
|
|
69
|
+
} = props;
|
|
70
|
+
const theme = useLoticsTheme();
|
|
71
|
+
const fill = color ?? theme.accent;
|
|
72
|
+
const isHorizontal = orientation === "horizontal";
|
|
73
|
+
const resolvedHeight = height ?? (isHorizontal ? Math.max(120, data.length * 56) : 220);
|
|
74
|
+
return (
|
|
75
|
+
<View style={{ height: resolvedHeight }}>
|
|
76
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
77
|
+
<BarChart
|
|
78
|
+
data={data}
|
|
79
|
+
layout={isHorizontal ? "vertical" : "horizontal"}
|
|
80
|
+
margin={
|
|
81
|
+
isHorizontal
|
|
82
|
+
? { top: 4, right: 40, left: 4, bottom: 4 }
|
|
83
|
+
: { top: 8, right: 0, left: 0, bottom: 0 }
|
|
84
|
+
}
|
|
85
|
+
barCategoryGap={isHorizontal ? "35%" : undefined}
|
|
86
|
+
>
|
|
87
|
+
<CartesianGrid
|
|
88
|
+
vertical={isHorizontal ? false : false}
|
|
89
|
+
horizontal={isHorizontal ? false : true}
|
|
90
|
+
stroke={colors.zinc[200]}
|
|
91
|
+
strokeDasharray="3 3"
|
|
92
|
+
/>
|
|
93
|
+
{isHorizontal ? (
|
|
94
|
+
<>
|
|
95
|
+
<XAxis type="number" hide />
|
|
96
|
+
<YAxis
|
|
97
|
+
type="category"
|
|
98
|
+
dataKey={categoryKey as string}
|
|
99
|
+
axisLine={false}
|
|
100
|
+
tickLine={false}
|
|
101
|
+
width={160}
|
|
102
|
+
tick={chartYAxisCategoryTickStyle}
|
|
103
|
+
/>
|
|
104
|
+
</>
|
|
105
|
+
) : (
|
|
106
|
+
<>
|
|
107
|
+
{/* Recharts v3's dataKey expects a TypedDataKey resolved from the
|
|
108
|
+
generic; our wrapper is generic over T so we pass the raw
|
|
109
|
+
string key. Cast at the boundary — consumers' data shape is
|
|
110
|
+
validated via the categoryKey: keyof T constraint. */}
|
|
111
|
+
<XAxis
|
|
112
|
+
dataKey={categoryKey as string}
|
|
113
|
+
axisLine={false}
|
|
114
|
+
tickLine={false}
|
|
115
|
+
tickMargin={8}
|
|
116
|
+
tick={chartAxisTickStyle}
|
|
117
|
+
/>
|
|
118
|
+
<YAxis hide />
|
|
119
|
+
</>
|
|
120
|
+
)}
|
|
121
|
+
<Tooltip
|
|
122
|
+
cursor={{ fill: colors.zinc[100] }}
|
|
123
|
+
contentStyle={chartTooltipStyle}
|
|
124
|
+
labelStyle={chartTooltipLabelStyle}
|
|
125
|
+
formatter={(val: unknown) => {
|
|
126
|
+
if (typeof val !== "number") return [String(val), valueLabel];
|
|
127
|
+
return [formatValue ? formatValue(val) : val.toLocaleString(), valueLabel];
|
|
128
|
+
}}
|
|
129
|
+
/>
|
|
130
|
+
<Bar
|
|
131
|
+
dataKey={valueKey as string}
|
|
132
|
+
fill={fill}
|
|
133
|
+
radius={isHorizontal ? [0, 4, 4, 0] : [6, 6, 0, 0]}
|
|
134
|
+
label={
|
|
135
|
+
isHorizontal
|
|
136
|
+
? {
|
|
137
|
+
position: "right",
|
|
138
|
+
fill: colors.zinc[700],
|
|
139
|
+
fontSize: 13,
|
|
140
|
+
fontWeight: 500,
|
|
141
|
+
}
|
|
142
|
+
: undefined
|
|
143
|
+
}
|
|
144
|
+
>
|
|
145
|
+
{fillKey &&
|
|
146
|
+
data.map((row, i) => (
|
|
147
|
+
<Cell key={i} fill={String(row[fillKey] ?? fill)} />
|
|
148
|
+
))}
|
|
149
|
+
</Bar>
|
|
150
|
+
</BarChart>
|
|
151
|
+
</ResponsiveContainer>
|
|
152
|
+
</View>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { colors } from "./colors";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared chart styling helpers + the shadcn-style tooltip box style.
|
|
5
|
+
* Centralized so every @lotics/ui chart wrapper looks identical without
|
|
6
|
+
* duplicating the magic numbers across files.
|
|
7
|
+
*
|
|
8
|
+
* Pure constants + a type — no JSX, no peer dep impact. The chart wrappers
|
|
9
|
+
* that import this also import Recharts directly; this file is safe to
|
|
10
|
+
* import from anywhere.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Tooltip popover box. Matches shadcn's `<ChartTooltipContent />` chrome:
|
|
14
|
+
* white card, hairline border, soft warm-neutral shadow, tight padding. */
|
|
15
|
+
export const chartTooltipStyle = {
|
|
16
|
+
backgroundColor: colors.white,
|
|
17
|
+
border: `1px solid ${colors.zinc[200]}`,
|
|
18
|
+
borderRadius: 10,
|
|
19
|
+
// Two-layer shadow: tight first stop for definition, soft second stop
|
|
20
|
+
// for atmosphere. Stripe / Linear pattern — single-shadow tooltips read
|
|
21
|
+
// as "default Recharts", layered shadow reads as "designed".
|
|
22
|
+
boxShadow: "0 1px 2px rgba(38,38,38,0.04), 0 8px 20px -4px rgba(38,38,38,0.08)",
|
|
23
|
+
fontSize: 13,
|
|
24
|
+
padding: "10px 14px",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export const chartTooltipLabelStyle = {
|
|
28
|
+
color: colors.zinc[600],
|
|
29
|
+
fontWeight: 500,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
/** Axis tick text — small, muted, no rotation. */
|
|
33
|
+
export const chartAxisTickStyle = {
|
|
34
|
+
fontSize: 12,
|
|
35
|
+
fill: colors.zinc[500],
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
/** Y-axis label text on horizontal bar charts — slightly heavier than X
|
|
39
|
+
* because it's reading a name, not a number. */
|
|
40
|
+
export const chartYAxisCategoryTickStyle = {
|
|
41
|
+
fontSize: 13,
|
|
42
|
+
fill: colors.zinc[700],
|
|
43
|
+
} as const;
|
package/src/dialog.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { colors } from "@lotics/ui/colors";
|
|
|
6
6
|
import { Button } from "@lotics/ui/button";
|
|
7
7
|
import { Text } from "@lotics/ui/text";
|
|
8
8
|
import { BackButton } from "@lotics/ui/back_button";
|
|
9
|
+
import { useOverlayScope } from "@lotics/ui/overlay_scope";
|
|
9
10
|
import {
|
|
10
11
|
ScreenRouterContext,
|
|
11
12
|
ScreenRouterInternalContext,
|
|
@@ -101,6 +102,8 @@ export function Dialog(props: DialogProps) {
|
|
|
101
102
|
const isControlled = controlledOpen !== undefined;
|
|
102
103
|
const open = isControlled ? controlledOpen : uncontrolledOpen;
|
|
103
104
|
|
|
105
|
+
useOverlayScope(open);
|
|
106
|
+
|
|
104
107
|
const routeRegistry = useRouteRegistry();
|
|
105
108
|
const { routerValue, internalValue, resetStack } = useNavigationStack(initialRoute, routeRegistry);
|
|
106
109
|
|
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>
|
|
@@ -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
|
+
}
|
package/src/popover.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { colors } from "./colors";
|
|
|
11
11
|
import { IconButton } from "./icon_button";
|
|
12
12
|
import { Portal } from "./portal";
|
|
13
13
|
import { Divider } from "./divider";
|
|
14
|
+
import { useOverlayScope } from "./overlay_scope";
|
|
14
15
|
import { PopoverNavContext, type PopoverNavContextValue } from "./popover_nav";
|
|
15
16
|
|
|
16
17
|
export type PopoverSide = "top" | "right" | "bottom" | "left";
|
|
@@ -76,6 +77,8 @@ export function Popover(props: PopoverProps) {
|
|
|
76
77
|
const isControlled = controlledOpen !== undefined;
|
|
77
78
|
const open = isControlled ? controlledOpen : uncontrolledOpen;
|
|
78
79
|
|
|
80
|
+
useOverlayScope(open);
|
|
81
|
+
|
|
79
82
|
const onOpenChange = useCallback(
|
|
80
83
|
(newOpen: boolean) => {
|
|
81
84
|
if (!isControlled) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { Card } from "./card";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { Divider } from "./divider";
|
|
6
|
+
import { SPACE } from "./spacing";
|
|
7
|
+
|
|
8
|
+
interface SectionCardProps {
|
|
9
|
+
title: string;
|
|
10
|
+
/**
|
|
11
|
+
* One-line context under the title. Mercury, Stripe, Linear all show this
|
|
12
|
+
* — a bare title says "what this is" but the description says "why this
|
|
13
|
+
* is here and what it answers". Skip only when the title is unambiguous
|
|
14
|
+
* on its own.
|
|
15
|
+
*/
|
|
16
|
+
description?: string;
|
|
17
|
+
/** Body content. Author owns the internal layout. */
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
/**
|
|
20
|
+
* Optional footer separated by a hairline. The right place for trend
|
|
21
|
+
* captions ("Tăng 12% so với tháng trước"), source notes, or
|
|
22
|
+
* cross-references. Skip if the body already self-explains.
|
|
23
|
+
*/
|
|
24
|
+
footer?: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Section-shaped card with semantic title + description + body + optional
|
|
29
|
+
* footer slots. The shape every well-designed dashboard section takes:
|
|
30
|
+
* Linear's checklist sections, Stripe's metric cards, Mercury's account
|
|
31
|
+
* panels. Mirrors the shadcn `Card` + `CardHeader` + `CardContent` +
|
|
32
|
+
* `CardFooter` composition pattern.
|
|
33
|
+
*
|
|
34
|
+
* Uses Card under the hood — picks up the border + soft shadow defaults.
|
|
35
|
+
* Adds generous 32px internal padding so the description + body + footer
|
|
36
|
+
* breathe; bare Card's 20px feels cramped once you have structured content.
|
|
37
|
+
*/
|
|
38
|
+
export function SectionCard(props: SectionCardProps) {
|
|
39
|
+
return (
|
|
40
|
+
<Card style={styles.card}>
|
|
41
|
+
<View style={styles.container}>
|
|
42
|
+
<View style={styles.header}>
|
|
43
|
+
<Text size="lg" weight="semibold">
|
|
44
|
+
{props.title}
|
|
45
|
+
</Text>
|
|
46
|
+
{props.description && (
|
|
47
|
+
<Text size="sm" color="muted">
|
|
48
|
+
{props.description}
|
|
49
|
+
</Text>
|
|
50
|
+
)}
|
|
51
|
+
</View>
|
|
52
|
+
{props.children}
|
|
53
|
+
{props.footer && (
|
|
54
|
+
<>
|
|
55
|
+
<Divider />
|
|
56
|
+
{props.footer}
|
|
57
|
+
</>
|
|
58
|
+
)}
|
|
59
|
+
</View>
|
|
60
|
+
</Card>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const styles = StyleSheet.create({
|
|
65
|
+
card: { padding: SPACE.xl }, // 32px — Stripe / Mercury internal padding
|
|
66
|
+
container: { gap: SPACE.lg },
|
|
67
|
+
header: { gap: SPACE.xs },
|
|
68
|
+
});
|
package/src/spacing.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 8-grid spacing tokens. Every gap, padding, and margin in a dashboard
|
|
3
|
+
* should come from this scale — multiples of 8 read as "designed",
|
|
4
|
+
* multiples of 4 read as "developer-tuned-on-the-fly", arbitrary numbers
|
|
5
|
+
* read as broken.
|
|
6
|
+
*
|
|
7
|
+
* Aliases (xs/sm/md/lg/xl/xxl) match the Text size scale so authors can
|
|
8
|
+
* reason about spacing and typography on the same vocabulary.
|
|
9
|
+
*
|
|
10
|
+
* Why not extend further (3xl, 4xl)? Lotics surfaces fit comfortably in
|
|
11
|
+
* the 4-48px range. A "between-section gap" of 64px+ usually means the
|
|
12
|
+
* sections should be on separate routes, not the same page.
|
|
13
|
+
*/
|
|
14
|
+
export const SPACE = {
|
|
15
|
+
xs: 4,
|
|
16
|
+
sm: 8,
|
|
17
|
+
md: 16,
|
|
18
|
+
lg: 24,
|
|
19
|
+
xl: 32,
|
|
20
|
+
xxl: 48,
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export type SpaceToken = keyof typeof SPACE;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
|
|
5
|
+
export interface SparklineProps {
|
|
6
|
+
/**
|
|
7
|
+
* Series of values in chronological order. Two or more required to draw
|
|
8
|
+
* a line; one value or empty renders an empty box of the requested
|
|
9
|
+
* height (preserves layout while data loads).
|
|
10
|
+
*/
|
|
11
|
+
data: number[];
|
|
12
|
+
/** Pixel height of the chart. Default 32. */
|
|
13
|
+
height?: number;
|
|
14
|
+
/** Pixel width. Default 100. */
|
|
15
|
+
width?: number;
|
|
16
|
+
/** Stroke color. Default `colors.zinc[700]`. */
|
|
17
|
+
color?: string;
|
|
18
|
+
/** Whether to fill the area under the line. Default false. */
|
|
19
|
+
filled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compact line trend, ~40px tall. Sits next to a metric to answer "is
|
|
24
|
+
* this good?" — static numbers answer "what" but not "trending up or
|
|
25
|
+
* down". Used in dashboards (Stripe, Mercury, Linear all ship this
|
|
26
|
+
* pattern).
|
|
27
|
+
*
|
|
28
|
+
* Implementation: native HTML `<svg>` rather than `react-native-svg`.
|
|
29
|
+
* Custom-code apps are web-only and Vite resolves react-native-svg's
|
|
30
|
+
* Fabric native paths incorrectly (Metro handles the platform variants
|
|
31
|
+
* but Vite doesn't). Wrapping in RN's `<View>` preserves the layout
|
|
32
|
+
* surface; SVG inside renders cleanly on the DOM.
|
|
33
|
+
*/
|
|
34
|
+
export function Sparkline(props: SparklineProps) {
|
|
35
|
+
const { data, height = 32, width = 100, color = colors.zinc[700], filled = false } = props;
|
|
36
|
+
|
|
37
|
+
const { path, fillPath } = useMemo(() => {
|
|
38
|
+
if (data.length < 2) return { path: null, fillPath: null };
|
|
39
|
+
const min = Math.min(...data);
|
|
40
|
+
const max = Math.max(...data);
|
|
41
|
+
// Flat data → horizontal line at vertical center. Avoid /0 division.
|
|
42
|
+
const range = max - min || 1;
|
|
43
|
+
// Stroke width budget — inset by 1.5px so the line doesn't clip the
|
|
44
|
+
// SVG viewport on either side.
|
|
45
|
+
const inset = 1.5;
|
|
46
|
+
const w = width - inset * 2;
|
|
47
|
+
const h = height - inset * 2;
|
|
48
|
+
const stepX = w / (data.length - 1);
|
|
49
|
+
let p = "";
|
|
50
|
+
data.forEach((v, i) => {
|
|
51
|
+
const x = inset + i * stepX;
|
|
52
|
+
// Y axis inverted in SVG (higher value = smaller y). Normalize to
|
|
53
|
+
// 0-1 then map into the inset area.
|
|
54
|
+
const y = inset + h - ((v - min) / range) * h;
|
|
55
|
+
p += `${i === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)} `;
|
|
56
|
+
});
|
|
57
|
+
const trimmed = p.trim();
|
|
58
|
+
return {
|
|
59
|
+
path: trimmed,
|
|
60
|
+
fillPath: filled
|
|
61
|
+
? `${trimmed} L ${width - inset} ${height - inset} L ${inset} ${height - inset} Z`
|
|
62
|
+
: null,
|
|
63
|
+
};
|
|
64
|
+
}, [data, width, height, filled]);
|
|
65
|
+
|
|
66
|
+
if (!path) {
|
|
67
|
+
return <View style={{ width, height }} />;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<View style={{ width, height }}>
|
|
72
|
+
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
|
73
|
+
{fillPath && <path d={fillPath} fill={color} fillOpacity={0.1} />}
|
|
74
|
+
<path
|
|
75
|
+
d={path}
|
|
76
|
+
stroke={color}
|
|
77
|
+
strokeWidth={1.5}
|
|
78
|
+
fill="none"
|
|
79
|
+
strokeLinejoin="round"
|
|
80
|
+
strokeLinecap="round"
|
|
81
|
+
/>
|
|
82
|
+
</svg>
|
|
83
|
+
</View>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { View, StyleSheet } from "react-native";
|
|
2
|
+
import { colors } from "./colors";
|
|
3
|
+
|
|
4
|
+
interface Segment {
|
|
5
|
+
/** Identifier for the segment — used as a React key. */
|
|
6
|
+
key: string;
|
|
7
|
+
/** Numeric weight. Each segment's width is `value / total`. */
|
|
8
|
+
value: number;
|
|
9
|
+
/** CSS color. */
|
|
10
|
+
color: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface StackedProgressBarProps {
|
|
14
|
+
segments: Segment[];
|
|
15
|
+
/** Total value across all segments. Pass explicitly so loading + empty
|
|
16
|
+
* states render consistently (an empty array would otherwise look like
|
|
17
|
+
* "data loaded with no values"). */
|
|
18
|
+
total: number;
|
|
19
|
+
/** Bar pixel height. Default 14 — the dashboard hero-progress size.
|
|
20
|
+
* Drop to 6-8 for inline status bars in tight rows; bump to 20-24
|
|
21
|
+
* when the bar IS the section's main visualization. */
|
|
22
|
+
height?: number;
|
|
23
|
+
loading?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Horizontal segmented bar for funnels / status breakdowns / category
|
|
28
|
+
* distributions. The Mercury / Linear stage-breakdown pattern — handles
|
|
29
|
+
* sparse data gracefully because zero-value segments collapse to zero
|
|
30
|
+
* width, and a single dominant value renders as one long segment rather
|
|
31
|
+
* than a "broken" bar chart with five empty stages.
|
|
32
|
+
*
|
|
33
|
+
* Pair with `<LegendItem />` rows below to name the colored segments.
|
|
34
|
+
* Without a legend, the colored bar is decorative — viewers can't map
|
|
35
|
+
* colors back to meaning.
|
|
36
|
+
*/
|
|
37
|
+
export function StackedProgressBar(props: StackedProgressBarProps) {
|
|
38
|
+
const { segments, total, height = 14, loading } = props;
|
|
39
|
+
if (loading || total === 0) {
|
|
40
|
+
return <View style={[styles.bar, { height, backgroundColor: colors.zinc[100] }]} />;
|
|
41
|
+
}
|
|
42
|
+
return (
|
|
43
|
+
<View style={[styles.bar, styles.barFilled, { height }]}>
|
|
44
|
+
{segments
|
|
45
|
+
.filter((s) => s.value > 0)
|
|
46
|
+
.map((seg) => (
|
|
47
|
+
<View
|
|
48
|
+
key={seg.key}
|
|
49
|
+
style={{ flex: seg.value, backgroundColor: seg.color }}
|
|
50
|
+
/>
|
|
51
|
+
))}
|
|
52
|
+
</View>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const styles = StyleSheet.create({
|
|
57
|
+
bar: {
|
|
58
|
+
borderRadius: 999,
|
|
59
|
+
overflow: "hidden",
|
|
60
|
+
},
|
|
61
|
+
barFilled: {
|
|
62
|
+
backgroundColor: colors.zinc[100],
|
|
63
|
+
flexDirection: "row",
|
|
64
|
+
},
|
|
65
|
+
});
|
package/src/text.css
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Letter-spacing curve: positive at small sizes (improves legibility for
|
|
3
|
+
* Vietnamese diacritics + Inter's `1`/`l`/`i` at 12px), neutral at body,
|
|
4
|
+
* negative at display. The single biggest "designed" tell on web type —
|
|
5
|
+
* Radix, Linear, Geist all use this curve.
|
|
6
|
+
*
|
|
7
|
+
* Inter stylistic alternates: `cv11` (single-storey `a`), `ss01` (alternate
|
|
8
|
+
* `1`), `ss03` (alternate `g`) — disambiguates similar glyphs without
|
|
9
|
+
* shifting metrics. No layout impact, premium-character signal.
|
|
10
|
+
*/
|
|
11
|
+
* {
|
|
12
|
+
font-feature-settings: "cv11", "ss01", "ss03";
|
|
13
|
+
}
|
|
14
|
+
|
|
1
15
|
[data-text-size="xs"] {
|
|
2
16
|
font-size: 12px;
|
|
3
17
|
line-height: 16px;
|
|
18
|
+
letter-spacing: 0.01em;
|
|
4
19
|
}
|
|
5
20
|
[data-text-size="sm"] {
|
|
6
21
|
font-size: 14px;
|
|
7
22
|
line-height: 20px;
|
|
23
|
+
letter-spacing: 0.0025em;
|
|
8
24
|
}
|
|
9
25
|
[data-text-size="md"] {
|
|
10
26
|
font-size: 16px;
|
|
@@ -13,14 +29,17 @@
|
|
|
13
29
|
[data-text-size="lg"] {
|
|
14
30
|
font-size: 18px;
|
|
15
31
|
line-height: 24px;
|
|
32
|
+
letter-spacing: -0.005em;
|
|
16
33
|
}
|
|
17
34
|
[data-text-size="xl"] {
|
|
18
|
-
font-size:
|
|
35
|
+
font-size: 28px;
|
|
19
36
|
line-height: 34px;
|
|
37
|
+
letter-spacing: -0.015em;
|
|
20
38
|
}
|
|
21
39
|
[data-text-size="xxl"] {
|
|
22
40
|
font-size: 32px;
|
|
23
41
|
line-height: 38px;
|
|
42
|
+
letter-spacing: -0.02em;
|
|
24
43
|
}
|
|
25
44
|
|
|
26
45
|
/* Refer to `use_screen_size` for breakpoints */
|
package/src/theme.tsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default platform accent — refined OKLCH blue. Used by chart fills,
|
|
5
|
+
* focus rings, and any primitive that asks "what's the brand color".
|
|
6
|
+
* Apps that need a different accent wrap their root in `LoticsThemeProvider`.
|
|
7
|
+
*
|
|
8
|
+
* Why OKLCH instead of hex? Perceptual uniformity — `oklch(0.6 0.118 250)`
|
|
9
|
+
* sits at the same perceptual lightness/saturation as the `oklch(0.6 0.118
|
|
10
|
+
* 184.704)` (teal) chị's workspace uses, just shifted in hue. Hex shifts
|
|
11
|
+
* lightness as hue rotates and the eye picks it up as inconsistency.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_ACCENT = "oklch(0.6 0.118 250)";
|
|
14
|
+
|
|
15
|
+
interface LoticsTheme {
|
|
16
|
+
/** Single brand accent. Chart fills, hero CTAs, focus rings. */
|
|
17
|
+
accent: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const LoticsThemeContext = createContext<LoticsTheme>({ accent: DEFAULT_ACCENT });
|
|
21
|
+
|
|
22
|
+
interface LoticsThemeProviderProps {
|
|
23
|
+
/** Brand accent. Overrides the platform default. Accepts any CSS color
|
|
24
|
+
* value (OKLCH recommended, hex / hsl also fine). */
|
|
25
|
+
accent?: string;
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* App-root provider that supplies brand tokens to @lotics/ui primitives.
|
|
31
|
+
* Wrap your top-level app element to override the platform defaults:
|
|
32
|
+
*
|
|
33
|
+
* // src/main.tsx
|
|
34
|
+
* <LoticsThemeProvider accent="oklch(0.6 0.118 184.704)">
|
|
35
|
+
* <App />
|
|
36
|
+
* </LoticsThemeProvider>
|
|
37
|
+
*
|
|
38
|
+
* Components that consume theme tokens use `useLoticsTheme()`. Each
|
|
39
|
+
* primitive also accepts a per-instance `color` prop for one-off
|
|
40
|
+
* customization without needing a different provider.
|
|
41
|
+
*
|
|
42
|
+
* Scope is intentionally narrow — accent only. Semantic colors (success,
|
|
43
|
+
* danger) already work via existing `colors.green[600]` etc. Adding more
|
|
44
|
+
* theme tokens requires a real product reason.
|
|
45
|
+
*/
|
|
46
|
+
export function LoticsThemeProvider(props: LoticsThemeProviderProps) {
|
|
47
|
+
const accent = props.accent ?? DEFAULT_ACCENT;
|
|
48
|
+
return (
|
|
49
|
+
<LoticsThemeContext.Provider value={{ accent }}>
|
|
50
|
+
{props.children}
|
|
51
|
+
</LoticsThemeContext.Provider>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read the current theme. Primitives that need the accent color call this
|
|
57
|
+
* hook; apps don't need it directly (use the provider's prop instead).
|
|
58
|
+
*/
|
|
59
|
+
export function useLoticsTheme(): LoticsTheme {
|
|
60
|
+
return useContext(LoticsThemeContext);
|
|
61
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { View, StyleSheet } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
|
|
5
|
+
export interface TrendChipProps {
|
|
6
|
+
/**
|
|
7
|
+
* Percent delta vs comparator. Positive = up, negative = down, 0 = flat.
|
|
8
|
+
* Convention: signed. `value={12}` → "↑ 12%". `value={-5}` → "↓ 5%".
|
|
9
|
+
*/
|
|
10
|
+
value: number;
|
|
11
|
+
/**
|
|
12
|
+
* Semantic override. By default, up = good (green) and down = bad (red).
|
|
13
|
+
* For metrics where down is good (e.g. response time, error rate), pass
|
|
14
|
+
* `goodDirection="down"` to flip the colors without changing the arrow.
|
|
15
|
+
*/
|
|
16
|
+
goodDirection?: "up" | "down";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Inline ±% chip that sits next to a metric. Answers "is this good?" — the
|
|
21
|
+
* single most common missing piece on dashboards that show only "what".
|
|
22
|
+
* Stripe, Mercury, Linear all ship this pattern.
|
|
23
|
+
*
|
|
24
|
+
* Color logic: trend direction × goodDirection. Up + good="up" = green.
|
|
25
|
+
* Up + good="down" = red. Symmetric for down. Flat = neutral.
|
|
26
|
+
*/
|
|
27
|
+
export function TrendChip(props: TrendChipProps) {
|
|
28
|
+
const { value, goodDirection = "up" } = props;
|
|
29
|
+
|
|
30
|
+
const direction = value > 0 ? "up" : value < 0 ? "down" : "flat";
|
|
31
|
+
const arrow = direction === "up" ? "↑" : direction === "down" ? "↓" : "→";
|
|
32
|
+
|
|
33
|
+
const isGood =
|
|
34
|
+
direction === "flat"
|
|
35
|
+
? null
|
|
36
|
+
: (direction === "up" && goodDirection === "up") ||
|
|
37
|
+
(direction === "down" && goodDirection === "down");
|
|
38
|
+
|
|
39
|
+
const tone = isGood === null ? "neutral" : isGood ? "good" : "bad";
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<View style={[styles.chip, styles[`chip_${tone}`]]}>
|
|
43
|
+
<Text size="xs" weight="medium" style={styles[`text_${tone}`]}>
|
|
44
|
+
{arrow} {Math.abs(value)}%
|
|
45
|
+
</Text>
|
|
46
|
+
</View>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const styles = StyleSheet.create({
|
|
51
|
+
chip: {
|
|
52
|
+
paddingHorizontal: 6,
|
|
53
|
+
paddingVertical: 2,
|
|
54
|
+
borderRadius: 4,
|
|
55
|
+
alignSelf: "flex-start",
|
|
56
|
+
},
|
|
57
|
+
// Tinted backgrounds (50-shade) keep the chip readable + non-shouty.
|
|
58
|
+
// Pairs with same-hue 700-shade text → 4.5+ contrast.
|
|
59
|
+
chip_good: { backgroundColor: colors.green[50] },
|
|
60
|
+
chip_bad: { backgroundColor: colors.red[50] },
|
|
61
|
+
chip_neutral: { backgroundColor: colors.zinc[100] },
|
|
62
|
+
text_good: { color: colors.green[700] },
|
|
63
|
+
text_bad: { color: colors.red[700] },
|
|
64
|
+
text_neutral: { color: colors.zinc[600] },
|
|
65
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { TrendingDown, TrendingUp } from "lucide-react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { colors } from "./colors";
|
|
5
|
+
import { SPACE } from "./spacing";
|
|
6
|
+
|
|
7
|
+
interface TrendFooterProps {
|
|
8
|
+
/** Signed percentage change. Positive = up, negative = down, 0 = no
|
|
9
|
+
* arrow. Caller is responsible for skipping the component entirely
|
|
10
|
+
* when there's no comparator (e.g., last-period base = 0). */
|
|
11
|
+
value: number;
|
|
12
|
+
/** Suffix after the percentage, e.g. "so với tháng trước". */
|
|
13
|
+
periodLabel: string;
|
|
14
|
+
/** Optional detail line below, e.g. "Tháng trước: 50.000.000 đ". */
|
|
15
|
+
detail?: string;
|
|
16
|
+
/** Override the up=green convention for metrics where down is good
|
|
17
|
+
* (response time, error rate). Defaults to `up`. */
|
|
18
|
+
goodDirection?: "up" | "down";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Footer caption that pairs the shadcn TrendingUp icon with a directional
|
|
23
|
+
* Vietnamese sentence — the "Tăng X% so với tháng trước" pattern at the
|
|
24
|
+
* bottom of every chart card on Stripe, Mercury, Linear.
|
|
25
|
+
*
|
|
26
|
+
* Goes inside `<SectionCard footer={...} />`. The Card adds the hairline
|
|
27
|
+
* divider above; this component owns only the icon + text composition.
|
|
28
|
+
*/
|
|
29
|
+
export function TrendFooter(props: TrendFooterProps) {
|
|
30
|
+
const { value, periodLabel, detail, goodDirection = "up" } = props;
|
|
31
|
+
const up = value > 0;
|
|
32
|
+
const flat = value === 0;
|
|
33
|
+
const isGood = flat ? null : (up && goodDirection === "up") || (!up && goodDirection === "down");
|
|
34
|
+
const color = isGood === null ? colors.zinc[600] : isGood ? colors.green[700] : colors.red[700];
|
|
35
|
+
const Icon = up ? TrendingUp : TrendingDown;
|
|
36
|
+
return (
|
|
37
|
+
<View style={styles.container}>
|
|
38
|
+
<View style={styles.row}>
|
|
39
|
+
<Icon size={16} color={color} />
|
|
40
|
+
<Text size="sm" weight="medium" style={{ color }}>
|
|
41
|
+
{up ? "Tăng" : "Giảm"} {Math.abs(value)}% {periodLabel}
|
|
42
|
+
</Text>
|
|
43
|
+
</View>
|
|
44
|
+
{detail && (
|
|
45
|
+
<Text size="xs" color="muted">
|
|
46
|
+
{detail}
|
|
47
|
+
</Text>
|
|
48
|
+
)}
|
|
49
|
+
</View>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const styles = StyleSheet.create({
|
|
54
|
+
container: { gap: SPACE.xs },
|
|
55
|
+
row: { flexDirection: "row", alignItems: "center", gap: SPACE.sm },
|
|
56
|
+
});
|