@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
package/package.json
CHANGED
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
7
7
|
"./colors": "./src/colors.ts",
|
|
8
|
+
"./mime": "./src/mime.ts",
|
|
9
|
+
"./file_badge": "./src/file_badge.tsx",
|
|
10
|
+
"./file_thumbnail": "./src/file_thumbnail.tsx",
|
|
11
|
+
"./file_gallery_modal": "./src/file_gallery_modal.tsx",
|
|
8
12
|
"./pagination": "./src/pagination.tsx",
|
|
9
13
|
"./bar_chart": "./src/bar_chart.tsx",
|
|
10
14
|
"./line_chart": "./src/line_chart.tsx",
|
|
11
15
|
"./pie_chart": "./src/pie_chart.tsx",
|
|
12
16
|
"./metric": "./src/metric.tsx",
|
|
17
|
+
"./sparkline": "./src/sparkline.tsx",
|
|
18
|
+
"./trend_chip": "./src/trend_chip.tsx",
|
|
19
|
+
"./section_card": "./src/section_card.tsx",
|
|
20
|
+
"./kpi_card": "./src/kpi_card.tsx",
|
|
21
|
+
"./alert_row": "./src/alert_row.tsx",
|
|
22
|
+
"./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
|
|
23
|
+
"./legend_item": "./src/legend_item.tsx",
|
|
24
|
+
"./trend_footer": "./src/trend_footer.tsx",
|
|
25
|
+
"./chart_area": "./src/chart_area.tsx",
|
|
26
|
+
"./chart_bar": "./src/chart_bar.tsx",
|
|
27
|
+
"./spacing": "./src/spacing.ts",
|
|
28
|
+
"./theme": "./src/theme.tsx",
|
|
13
29
|
"./progress_bar": "./src/progress_bar.tsx",
|
|
14
30
|
"./form_date_picker": "./src/form_date_picker.tsx",
|
|
15
31
|
"./form_time_picker": "./src/form_time_picker.tsx",
|
|
@@ -25,6 +41,7 @@
|
|
|
25
41
|
"./portal": "./src/portal.tsx",
|
|
26
42
|
"./popover_nav": "./src/popover_nav.tsx",
|
|
27
43
|
"./popover": "./src/popover.tsx",
|
|
44
|
+
"./overlay_scope": "./src/overlay_scope.ts",
|
|
28
45
|
"./switcher": "./src/switcher.tsx",
|
|
29
46
|
"./menu_button": "./src/menu_button.tsx",
|
|
30
47
|
"./menu_list_item": "./src/menu_list_item.tsx",
|
|
@@ -130,13 +147,15 @@
|
|
|
130
147
|
"lucide-react": ">=0.460.0",
|
|
131
148
|
"react-native-svg": ">=15.0.0",
|
|
132
149
|
"expo-image": ">=3.0.0",
|
|
133
|
-
"@react-native-picker/picker": ">=2.0.0"
|
|
150
|
+
"@react-native-picker/picker": ">=2.0.0",
|
|
151
|
+
"recharts": ">=3.0.0"
|
|
134
152
|
},
|
|
135
153
|
"peerDependenciesMeta": {
|
|
136
154
|
"expo-image": { "optional": true },
|
|
137
155
|
"@react-native-picker/picker": { "optional": true },
|
|
138
156
|
"lucide-react-native": { "optional": true },
|
|
139
|
-
"react-native-svg": { "optional": true }
|
|
157
|
+
"react-native-svg": { "optional": true },
|
|
158
|
+
"recharts": { "optional": true }
|
|
140
159
|
},
|
|
141
160
|
"scripts": {
|
|
142
161
|
"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
|
|