@lotics/ui 2.4.1 → 2.6.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 +28 -8
- package/src/accordion.tsx +146 -63
- package/src/action_menu.tsx +72 -0
- package/src/allocation_row.tsx +54 -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/skeleton.tsx +47 -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,50 @@
|
|
|
1
|
+
import { StyleSheet } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
4
|
+
|
|
5
|
+
export interface LinkButtonProps {
|
|
6
|
+
title: string;
|
|
7
|
+
onPress: () => void;
|
|
8
|
+
/** Defaults to `title`. */
|
|
9
|
+
accessibilityLabel?: string;
|
|
10
|
+
testID?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A quiet inline action — "Clear", "Select all", "Show more" — styled as a GHOST
|
|
15
|
+
* button: dark medium text with no chrome at rest and a soft rounded hover wash,
|
|
16
|
+
* compact (not the 40px `Button`). The low-emphasis sibling of `Button` for
|
|
17
|
+
* utility actions in a popover footer or a list's select-all row; it never
|
|
18
|
+
* competes with a real/commit button, and reads more premium than an underlined
|
|
19
|
+
* link or a filled/muted Button.
|
|
20
|
+
*/
|
|
21
|
+
export function LinkButton(props: LinkButtonProps) {
|
|
22
|
+
const { title, onPress, accessibilityLabel, testID } = props;
|
|
23
|
+
return (
|
|
24
|
+
<PressableHighlight
|
|
25
|
+
testID={testID}
|
|
26
|
+
onPress={onPress}
|
|
27
|
+
accessibilityRole="button"
|
|
28
|
+
accessibilityLabel={accessibilityLabel ?? title}
|
|
29
|
+
userSelect="none"
|
|
30
|
+
style={styles.btn}
|
|
31
|
+
>
|
|
32
|
+
<Text size="sm" weight="medium">
|
|
33
|
+
{title}
|
|
34
|
+
</Text>
|
|
35
|
+
</PressableHighlight>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const styles = StyleSheet.create({
|
|
40
|
+
// The hover wash needs horizontal padding, but the negative margin cancels it
|
|
41
|
+
// for POSITIONING — so the text sits flush with the surrounding content's left
|
|
42
|
+
// edge (the option rows above, the editor body) while the wash bleeds outward.
|
|
43
|
+
btn: {
|
|
44
|
+
paddingVertical: 6,
|
|
45
|
+
paddingHorizontal: 8,
|
|
46
|
+
marginHorizontal: -8,
|
|
47
|
+
borderRadius: 8,
|
|
48
|
+
alignSelf: "flex-start",
|
|
49
|
+
},
|
|
50
|
+
});
|
package/src/metric.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { View, StyleSheet, type TextStyle } from "react-native";
|
|
2
2
|
import { Text } from "./text";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
|
+
import { formatCompactNumber, formatMoney } from "./format_money";
|
|
4
5
|
import { useMemo } from "react";
|
|
5
6
|
|
|
6
7
|
export type MetricFormat = "currency" | "number" | "percentage" | "none";
|
|
@@ -19,6 +20,11 @@ export type MetricSize = "sm" | "md" | "lg" | "hero";
|
|
|
19
20
|
|
|
20
21
|
export interface MetricProps {
|
|
21
22
|
value: number | string | null | undefined;
|
|
23
|
+
/** Abbreviate large numeric values for display density: ≥1 tỷ → "1,28 tỷ",
|
|
24
|
+
* ≥1 triệu → "486 tr" (vi convention; other locales fall back to compact
|
|
25
|
+
* notation). For stat strips/cards where the full figure lives in the
|
|
26
|
+
* table below — never for the table itself. */
|
|
27
|
+
compact?: boolean;
|
|
22
28
|
previousValue?: number | string | null | undefined;
|
|
23
29
|
format?: MetricFormat;
|
|
24
30
|
currency?: string;
|
|
@@ -59,26 +65,27 @@ export function Metric(props: MetricProps) {
|
|
|
59
65
|
value,
|
|
60
66
|
previousValue,
|
|
61
67
|
format,
|
|
62
|
-
currency = "
|
|
68
|
+
currency = "VND",
|
|
63
69
|
locale,
|
|
64
70
|
emptyLabel = "-",
|
|
65
71
|
tone = "default",
|
|
66
72
|
size = "md",
|
|
73
|
+
compact = false,
|
|
67
74
|
} = props;
|
|
68
75
|
|
|
69
76
|
const displayValue = useMemo(() => {
|
|
77
|
+
// vi-VN by default on every branch — "18,4%", not "18.4%". A system
|
|
78
|
+
// locale fallback makes the same dashboard render differently per
|
|
79
|
+
// machine.
|
|
80
|
+
const resolvedLocale = locale ?? "vi-VN";
|
|
70
81
|
if (value === null || value === undefined) return emptyLabel;
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (format === "number"
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
if (format === "percentage" && typeof value === "number") {
|
|
78
|
-
return `${value.toLocaleString(locale)}%`;
|
|
79
|
-
}
|
|
82
|
+
if (typeof value !== "number") return String(value);
|
|
83
|
+
if (format === "currency") return formatMoney(value, { locale: resolvedLocale, currency, compact });
|
|
84
|
+
if (compact && Math.abs(value) >= 1_000_000) return formatCompactNumber(value, resolvedLocale);
|
|
85
|
+
if (format === "number") return value.toLocaleString(resolvedLocale);
|
|
86
|
+
if (format === "percentage") return `${value.toLocaleString(resolvedLocale)}%`;
|
|
80
87
|
return String(value);
|
|
81
|
-
}, [value, format, currency, locale, emptyLabel]);
|
|
88
|
+
}, [value, format, currency, locale, emptyLabel, compact]);
|
|
82
89
|
|
|
83
90
|
const trend = useMemo(() => {
|
|
84
91
|
if (previousValue === undefined || previousValue === null) return null;
|
|
@@ -139,8 +146,10 @@ export function Metric(props: MetricProps) {
|
|
|
139
146
|
}
|
|
140
147
|
|
|
141
148
|
const styles = StyleSheet.create({
|
|
149
|
+
// No flex grow/shrink: the value keeps its intrinsic width so that in a
|
|
150
|
+
// wrap-row (KPICard's value + trend chip) the CHIP drops to the next line
|
|
151
|
+
// when space runs out — never the number breaking mid-value.
|
|
142
152
|
container: {
|
|
143
|
-
flex: 1,
|
|
144
153
|
justifyContent: "center",
|
|
145
154
|
alignItems: "flex-start",
|
|
146
155
|
gap: 4,
|
package/src/pagination.tsx
CHANGED
|
@@ -2,7 +2,6 @@ import * as React from "react";
|
|
|
2
2
|
import { View, StyleSheet } from "react-native";
|
|
3
3
|
import { IconButton } from "./icon_button";
|
|
4
4
|
import { Text } from "./text";
|
|
5
|
-
import { colors } from "./colors";
|
|
6
5
|
|
|
7
6
|
export interface PaginationProps {
|
|
8
7
|
/** 0-indexed. */
|
|
@@ -22,9 +21,11 @@ export interface PaginationProps {
|
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
|
-
* Pagination
|
|
26
|
-
* right.
|
|
27
|
-
*
|
|
24
|
+
* Pagination control: range summary on the left, page indicator + arrows on the
|
|
25
|
+
* right. A pure control — it owns no border, background, or padding. The
|
|
26
|
+
* container that frames it (a `CardFooter`, a grid footer, a bordered band)
|
|
27
|
+
* supplies the divider and insets, so the same control composes anywhere without
|
|
28
|
+
* doubling up a border.
|
|
28
29
|
*/
|
|
29
30
|
export function Pagination(props: PaginationProps): React.ReactNode {
|
|
30
31
|
const { page, pageSize, rowCount, hasMore, total, loading, onPageChange } = props;
|
|
@@ -78,11 +79,6 @@ const styles = StyleSheet.create({
|
|
|
78
79
|
flexDirection: "row",
|
|
79
80
|
alignItems: "center",
|
|
80
81
|
gap: 12,
|
|
81
|
-
paddingVertical: 8,
|
|
82
|
-
paddingHorizontal: 12,
|
|
83
|
-
borderTopWidth: 1,
|
|
84
|
-
borderTopColor: colors.border,
|
|
85
|
-
backgroundColor: colors.background,
|
|
86
82
|
},
|
|
87
83
|
summary: { flex: 1 },
|
|
88
84
|
controls: {
|
package/src/peek.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet } from "react-native";
|
|
3
|
+
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
4
|
+
import type { PopoverSide, PopoverAlign } from "./popover";
|
|
5
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
6
|
+
|
|
7
|
+
export interface PeekProps {
|
|
8
|
+
/** The inline reference that becomes the trigger — a customer name, a
|
|
9
|
+
* record id, a member chip. Rendered inside a PressableHighlight (hover
|
|
10
|
+
* wash signals pressability without restyling the text). */
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
/** The instant detail revealed on press — compose Text/Divider/Button
|
|
13
|
+
* rows. Keep it a summary with ONE action to the full record; a peek
|
|
14
|
+
* that needs scrolling wanted to be a screen. */
|
|
15
|
+
content: ReactNode;
|
|
16
|
+
/** Announced name for the trigger ("Hồ sơ KOMASPEC VIỆT NAM"). */
|
|
17
|
+
accessibilityLabel: string;
|
|
18
|
+
side?: PopoverSide;
|
|
19
|
+
align?: PopoverAlign;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Drill-down for inline references — press a name/id where it appears and
|
|
24
|
+
* get its details in a popover, without leaving the screen. The companion
|
|
25
|
+
* to the count drill-downs (KPIStrip `onPress`, Accordion rows): counts
|
|
26
|
+
* open the records behind a number, Peek opens the record behind a
|
|
27
|
+
* reference. Use it on every entity mention that has more to say —
|
|
28
|
+
* customer names, order ids, member names, linked records.
|
|
29
|
+
*/
|
|
30
|
+
export function Peek(props: PeekProps) {
|
|
31
|
+
const { children, content, accessibilityLabel, side = "bottom", align = "start" } = props;
|
|
32
|
+
return (
|
|
33
|
+
<Popover side={side} align={align}>
|
|
34
|
+
<PopoverTrigger>
|
|
35
|
+
<PressableHighlight
|
|
36
|
+
accessibilityRole="button"
|
|
37
|
+
accessibilityLabel={accessibilityLabel}
|
|
38
|
+
style={styles.trigger}
|
|
39
|
+
// 32px visual, 40px touch target (32 + 2×4) — same as IconButton.
|
|
40
|
+
hitSlop={4}
|
|
41
|
+
>
|
|
42
|
+
{children}
|
|
43
|
+
</PressableHighlight>
|
|
44
|
+
</PopoverTrigger>
|
|
45
|
+
<PopoverContent style={styles.content} disableBodyScroll>
|
|
46
|
+
{content}
|
|
47
|
+
</PopoverContent>
|
|
48
|
+
</Popover>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const styles = StyleSheet.create({
|
|
53
|
+
// Inline bleed at the 32px chip register (matches Badge scale, fits inside
|
|
54
|
+
// text rows without inflating them); negative margins absorb the extra
|
|
55
|
+
// height so the line's layout never shifts. hitSlop keeps the 40px target.
|
|
56
|
+
trigger: {
|
|
57
|
+
flexDirection: "row",
|
|
58
|
+
alignItems: "center",
|
|
59
|
+
minHeight: 32,
|
|
60
|
+
borderRadius: 8,
|
|
61
|
+
paddingHorizontal: 8,
|
|
62
|
+
marginHorizontal: -8,
|
|
63
|
+
marginVertical: -4,
|
|
64
|
+
},
|
|
65
|
+
content: {
|
|
66
|
+
width: 320,
|
|
67
|
+
},
|
|
68
|
+
});
|
package/src/picker.tsx
CHANGED
|
@@ -33,6 +33,10 @@ export type PickerOnClose<T extends string, MULTI extends boolean> = MULTI exten
|
|
|
33
33
|
export interface PickerProps<T extends string = string, MULTI extends boolean = false> {
|
|
34
34
|
options?: (PickerOption<T> | undefined | false)[];
|
|
35
35
|
placeholder?: string;
|
|
36
|
+
/** Accessible name for the control. Required in spirit whenever the picker
|
|
37
|
+
* has no visible label next to it (e.g. an unlabeled toolbar filter) — the
|
|
38
|
+
* selected option's text describes the value, not the control. */
|
|
39
|
+
accessibilityLabel?: string;
|
|
36
40
|
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
37
41
|
style?: StyleProp<ViewStyle>;
|
|
38
42
|
testID?: string;
|
|
@@ -70,6 +74,7 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
|
|
|
70
74
|
includeEmptyOption,
|
|
71
75
|
onValueChange,
|
|
72
76
|
placeholder,
|
|
77
|
+
accessibilityLabel,
|
|
73
78
|
style,
|
|
74
79
|
disabled = false,
|
|
75
80
|
autoFocus = false,
|
|
@@ -105,6 +110,7 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
|
|
|
105
110
|
<RNPicker
|
|
106
111
|
ref={pickerRef}
|
|
107
112
|
testID={testID}
|
|
113
|
+
accessibilityLabel={accessibilityLabel}
|
|
108
114
|
// Empty selection maps to "" (the placeholder option's value), never
|
|
109
115
|
// undefined — otherwise the native <select> goes uncontrolled and a
|
|
110
116
|
// programmatic reset to null leaves the prior DOM selection in place.
|
|
@@ -144,6 +150,7 @@ function CustomPicker<T extends string, MULTI extends boolean = false>(
|
|
|
144
150
|
options = [],
|
|
145
151
|
value,
|
|
146
152
|
placeholder,
|
|
153
|
+
accessibilityLabel,
|
|
147
154
|
multi = false as MULTI,
|
|
148
155
|
renderOptionContent,
|
|
149
156
|
includeEmptyOption,
|
|
@@ -188,6 +195,7 @@ function CustomPicker<T extends string, MULTI extends boolean = false>(
|
|
|
188
195
|
renderOptionContent={renderOptionContent}
|
|
189
196
|
selectedItems={selectedItems}
|
|
190
197
|
placeholder={placeholder}
|
|
198
|
+
accessibilityLabel={accessibilityLabel}
|
|
191
199
|
disabled={disabled}
|
|
192
200
|
/>
|
|
193
201
|
</PopoverTrigger>
|
|
@@ -244,6 +252,7 @@ function PickerTrigger<T extends string>({
|
|
|
244
252
|
renderOptionContent,
|
|
245
253
|
selectedItems,
|
|
246
254
|
placeholder,
|
|
255
|
+
accessibilityLabel,
|
|
247
256
|
disabled = false,
|
|
248
257
|
}: {
|
|
249
258
|
ref?: React.Ref<View>;
|
|
@@ -254,6 +263,7 @@ function PickerTrigger<T extends string>({
|
|
|
254
263
|
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
255
264
|
selectedItems: PickerOption<T>[];
|
|
256
265
|
placeholder?: string;
|
|
266
|
+
accessibilityLabel?: string;
|
|
257
267
|
disabled?: boolean;
|
|
258
268
|
}) {
|
|
259
269
|
const hasSelection = selectedItems.length > 0;
|
|
@@ -266,8 +276,10 @@ function PickerTrigger<T extends string>({
|
|
|
266
276
|
// the trigger drops out of the tab order and Enter/Space can't open it.
|
|
267
277
|
// `button` makes it tab-focusable and maps keyboard activation to onPress;
|
|
268
278
|
// `expanded` announces open/closed to assistive tech. The accessible name
|
|
269
|
-
// comes from
|
|
279
|
+
// comes from `accessibilityLabel` when given (an unlabeled control), else
|
|
280
|
+
// the visible selection/placeholder text below.
|
|
270
281
|
accessibilityRole="button"
|
|
282
|
+
accessibilityLabel={accessibilityLabel}
|
|
271
283
|
accessibilityState={{ expanded: open, disabled }}
|
|
272
284
|
style={[styles.pressable, open && styles.opened, disabled && styles.disabled, style]}
|
|
273
285
|
onPress={!disabled ? onPress : undefined}
|
package/src/picker_menu.tsx
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { StyleSheet, View, ScrollView,
|
|
1
|
+
import { StyleSheet, View, ScrollView, TextInput } from "react-native";
|
|
2
2
|
import { useState, useCallback, useMemo, useRef } from "react";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { Icon } from "./icon";
|
|
6
6
|
import { Checkbox } from "./checkbox";
|
|
7
|
-
import { Spacer } from "./spacer";
|
|
8
7
|
import { MenuButton } from "./menu_button";
|
|
8
|
+
import { LinkButton } from "./link_button";
|
|
9
9
|
import { ActivityIndicator } from "./activity_indicator";
|
|
10
10
|
import { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
|
|
11
11
|
import { useScreenSize } from "./use_screen_size";
|
|
@@ -311,17 +311,8 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
311
311
|
</ScrollView>
|
|
312
312
|
{showLinks && (
|
|
313
313
|
<View style={styles.selectAllContainer}>
|
|
314
|
-
{showSelectAllLink &&
|
|
315
|
-
|
|
316
|
-
<Text color="zinc-500">{selectAllLabel}</Text>
|
|
317
|
-
</Pressable>
|
|
318
|
-
)}
|
|
319
|
-
<Spacer horizontal size={16} />
|
|
320
|
-
{showDeselectAllLink && (
|
|
321
|
-
<Pressable onPress={handleDeselectAll}>
|
|
322
|
-
<Text color="zinc-500">{deselectAllLabel}</Text>
|
|
323
|
-
</Pressable>
|
|
324
|
-
)}
|
|
314
|
+
{showSelectAllLink && <LinkButton title={selectAllLabel} onPress={handleSelectAll} />}
|
|
315
|
+
{showDeselectAllLink && <LinkButton title={deselectAllLabel} onPress={handleDeselectAll} />}
|
|
325
316
|
</View>
|
|
326
317
|
)}
|
|
327
318
|
</View>
|
|
@@ -339,9 +330,10 @@ const styles = StyleSheet.create({
|
|
|
339
330
|
selectAllContainer: {
|
|
340
331
|
flexDirection: "row",
|
|
341
332
|
alignItems: "center",
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
333
|
+
gap: 16,
|
|
334
|
+
paddingVertical: 4,
|
|
335
|
+
// Matches a MenuButton option's 8px text inset, so the bleeding-wash
|
|
336
|
+
// LinkButtons line up under the option labels above.
|
|
345
337
|
paddingHorizontal: 8,
|
|
346
338
|
borderTopWidth: 1,
|
|
347
339
|
borderTopColor: colors.border,
|
package/src/pie_chart.tsx
CHANGED
|
@@ -35,6 +35,8 @@ export interface PieChartProps {
|
|
|
35
35
|
showLegend?: boolean;
|
|
36
36
|
formatNumber?: (n: number) => string;
|
|
37
37
|
emptyLabel?: string;
|
|
38
|
+
/** Caption under the center total. Pass `null` to hide the center. */
|
|
39
|
+
centerLabel?: string | null;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export function PieChart(props: PieChartProps) {
|
|
@@ -44,6 +46,7 @@ export function PieChart(props: PieChartProps) {
|
|
|
44
46
|
showLegend = true,
|
|
45
47
|
formatNumber = defaultFormatNumber,
|
|
46
48
|
emptyLabel = "No data",
|
|
49
|
+
centerLabel = "Total",
|
|
47
50
|
} = props;
|
|
48
51
|
|
|
49
52
|
const [isVerticalLayout, setIsVerticalLayout] = useState(false);
|
|
@@ -66,7 +69,7 @@ export function PieChart(props: PieChartProps) {
|
|
|
66
69
|
|
|
67
70
|
const center = size / 2;
|
|
68
71
|
const outerRadius = size / 2 - 4;
|
|
69
|
-
const innerRadius = outerRadius * 0.
|
|
72
|
+
const innerRadius = outerRadius * 0.66;
|
|
70
73
|
const drawn: { path: string; color: string }[] = [];
|
|
71
74
|
let currentAngle = -Math.PI / 2;
|
|
72
75
|
|
|
@@ -113,14 +116,25 @@ export function PieChart(props: PieChartProps) {
|
|
|
113
116
|
return (
|
|
114
117
|
<View style={styles.chartContainer} onLayout={handleLayout}>
|
|
115
118
|
<View style={[styles.chartWrapper, isVerticalLayout && styles.chartWrapperVertical]}>
|
|
116
|
-
<View style={styles.pieContainer}>
|
|
119
|
+
<View style={[styles.pieContainer, { width: size, height: size }]}>
|
|
117
120
|
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
|
118
121
|
<G>
|
|
119
122
|
{pieSlices.map((slice, index) => (
|
|
120
|
-
|
|
123
|
+
// white seams separate the slices — segments, not a color wheel
|
|
124
|
+
<Path key={index} d={slice.path} fill={slice.color} stroke={colors.white} strokeWidth={2} />
|
|
121
125
|
))}
|
|
122
126
|
</G>
|
|
123
127
|
</Svg>
|
|
128
|
+
{centerLabel !== null ? (
|
|
129
|
+
<View style={styles.center} pointerEvents="none">
|
|
130
|
+
<Text size="lg" weight="semibold" tabular>
|
|
131
|
+
{formatNumber(total)}
|
|
132
|
+
</Text>
|
|
133
|
+
<Text size="xs" color="muted">
|
|
134
|
+
{centerLabel}
|
|
135
|
+
</Text>
|
|
136
|
+
</View>
|
|
137
|
+
) : null}
|
|
124
138
|
</View>
|
|
125
139
|
{showLegend && (
|
|
126
140
|
<View style={[styles.legend, isVerticalLayout && styles.legendVertical]}>
|
|
@@ -132,9 +146,9 @@ export function PieChart(props: PieChartProps) {
|
|
|
132
146
|
<Text size="sm" numberOfLines={1} weight="medium" style={styles.legendLabel}>
|
|
133
147
|
{item.label}
|
|
134
148
|
</Text>
|
|
135
|
-
<Text style={styles.legendValue}>{formatNumber(item.value)}</Text>
|
|
136
|
-
<Text color="zinc-500" style={styles.legendPercentage}>
|
|
137
|
-
|
|
149
|
+
<Text size="sm" tabular style={styles.legendValue}>{formatNumber(item.value)}</Text>
|
|
150
|
+
<Text size="sm" color="zinc-500" tabular style={styles.legendPercentage}>
|
|
151
|
+
{percentage.toFixed(0)}%
|
|
138
152
|
</Text>
|
|
139
153
|
</View>
|
|
140
154
|
);
|
|
@@ -163,6 +177,13 @@ const styles = StyleSheet.create({
|
|
|
163
177
|
},
|
|
164
178
|
pieContainer: {
|
|
165
179
|
flexShrink: 0,
|
|
180
|
+
alignItems: "center",
|
|
181
|
+
justifyContent: "center",
|
|
182
|
+
},
|
|
183
|
+
center: {
|
|
184
|
+
position: "absolute",
|
|
185
|
+
alignItems: "center",
|
|
186
|
+
gap: 0,
|
|
166
187
|
},
|
|
167
188
|
legend: {
|
|
168
189
|
flex: 1,
|
|
@@ -187,10 +208,10 @@ const styles = StyleSheet.create({
|
|
|
187
208
|
},
|
|
188
209
|
legendValue: {
|
|
189
210
|
minWidth: 40,
|
|
190
|
-
textAlign: "
|
|
211
|
+
textAlign: "right",
|
|
191
212
|
},
|
|
192
213
|
legendPercentage: {
|
|
193
|
-
minWidth:
|
|
214
|
+
minWidth: 40,
|
|
194
215
|
textAlign: "right",
|
|
195
216
|
},
|
|
196
217
|
});
|
package/src/pill_button.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Ref } from "react";
|
|
2
|
-
import { colors } from "./colors";
|
|
3
2
|
import { IconButton } from "./icon_button";
|
|
4
3
|
import { PressableHighlight } from "./pressable_highlight";
|
|
4
|
+
import { pillSurfaceStyle } from "./control_surface";
|
|
5
5
|
import { StyleSheet, View } from "react-native";
|
|
6
6
|
|
|
7
7
|
interface PillButtonProps {
|
|
@@ -19,11 +19,15 @@ export function PillButton(props: PillButtonProps) {
|
|
|
19
19
|
return (
|
|
20
20
|
<View ref={ref}>
|
|
21
21
|
{onPress ? (
|
|
22
|
-
<PressableHighlight
|
|
22
|
+
<PressableHighlight
|
|
23
|
+
testID={testID}
|
|
24
|
+
onPress={onPress}
|
|
25
|
+
style={(state) => [pillSurfaceStyle(state), styles.pillLayout, onDismiss && styles.pillWithDismiss]}
|
|
26
|
+
>
|
|
23
27
|
{children}
|
|
24
28
|
</PressableHighlight>
|
|
25
29
|
) : (
|
|
26
|
-
<View style={[styles.
|
|
30
|
+
<View style={[pillSurfaceStyle({}), styles.pillLayout, onDismiss && styles.pillWithDismiss]}>{children}</View>
|
|
27
31
|
)}
|
|
28
32
|
{onDismiss && (
|
|
29
33
|
<View style={styles.dismissButton}>
|
|
@@ -41,15 +45,13 @@ export function PillButton(props: PillButtonProps) {
|
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
const styles = StyleSheet.create({
|
|
44
|
-
|
|
48
|
+
// Surface (height/border/radius/white + hover) comes from pillSurfaceStyle;
|
|
49
|
+
// PillButton owns only its row layout.
|
|
50
|
+
pillLayout: {
|
|
45
51
|
flexDirection: "row",
|
|
46
52
|
alignItems: "center",
|
|
47
53
|
gap: 4,
|
|
48
|
-
height: 40,
|
|
49
54
|
paddingHorizontal: 12,
|
|
50
|
-
borderRadius: 999,
|
|
51
|
-
borderWidth: 1,
|
|
52
|
-
borderColor: colors.border,
|
|
53
55
|
},
|
|
54
56
|
pillWithDismiss: {
|
|
55
57
|
paddingRight: 38,
|
package/src/popover.tsx
CHANGED
|
@@ -519,7 +519,7 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
519
519
|
tabIndex={small ? undefined : -1}
|
|
520
520
|
style={{
|
|
521
521
|
position: "fixed",
|
|
522
|
-
padding:
|
|
522
|
+
padding: 12,
|
|
523
523
|
borderTopLeftRadius: 16,
|
|
524
524
|
borderTopRightRadius: 16,
|
|
525
525
|
borderBottomLeftRadius: small ? 0 : 16,
|
|
@@ -595,15 +595,25 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
595
595
|
export interface PopoverFooterProps {
|
|
596
596
|
children: React.ReactNode;
|
|
597
597
|
showDivider?: boolean;
|
|
598
|
+
/** Action Layout rules: the closure/commit action sits RIGHT (`end`,
|
|
599
|
+
* the default); utility-left + commit-right → `space-between`;
|
|
600
|
+
* informational/custom-width footers → `start`. */
|
|
601
|
+
align?: "start" | "end" | "space-between";
|
|
598
602
|
}
|
|
599
603
|
|
|
600
604
|
export function PopoverFooter(props: PopoverFooterProps) {
|
|
601
|
-
const { children, showDivider = true } = props;
|
|
605
|
+
const { children, showDivider = true, align = "end" } = props;
|
|
606
|
+
const justifyContent =
|
|
607
|
+
align === "end" ? "flex-end" : align === "space-between" ? "space-between" : "flex-start";
|
|
602
608
|
|
|
603
609
|
return (
|
|
604
|
-
|
|
610
|
+
// Pull out to the popover's edges (counteract its 12px inset) so the divider
|
|
611
|
+
// spans full width; the action row then re-insets to align with the body.
|
|
612
|
+
<View style={{ marginHorizontal: -12, marginTop: 12 }}>
|
|
605
613
|
{showDivider && <Divider />}
|
|
606
|
-
<View style={{
|
|
614
|
+
<View style={{ paddingHorizontal: 12, paddingTop: 12, flexDirection: "row", alignItems: "center", gap: 8, justifyContent }}>
|
|
615
|
+
{children}
|
|
616
|
+
</View>
|
|
607
617
|
</View>
|
|
608
618
|
);
|
|
609
619
|
}
|
|
@@ -32,6 +32,14 @@ export interface PressableHighlightProps extends PressableProps {
|
|
|
32
32
|
* explicitly because the base React Native `PressableProps` type omits it.
|
|
33
33
|
*/
|
|
34
34
|
onKeyDown?: (event: { key: string; preventDefault?: () => void }) => void;
|
|
35
|
+
/**
|
|
36
|
+
* Pass "none" on row/card surfaces: a pressable surface is a button, not a
|
|
37
|
+
* text-selection surface — drag jitter on selectable text starts a
|
|
38
|
+
* selection and can swallow the click. Exposed as a prop because RN types
|
|
39
|
+
* only carry `userSelect` on TextStyle, while react-native-web applies it
|
|
40
|
+
* to any element.
|
|
41
|
+
*/
|
|
42
|
+
userSelect?: "auto" | "none";
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
/**
|
|
@@ -47,6 +55,7 @@ export function PressableHighlight(props: PressableHighlightProps) {
|
|
|
47
55
|
tooltip,
|
|
48
56
|
tooltipSide = "top",
|
|
49
57
|
onPress,
|
|
58
|
+
userSelect,
|
|
50
59
|
...restPressableProps
|
|
51
60
|
} = props;
|
|
52
61
|
const tooltipProps = useTooltip({
|
|
@@ -71,7 +80,7 @@ export function PressableHighlight(props: PressableHighlightProps) {
|
|
|
71
80
|
const hovered = (state as { hovered?: boolean }).hovered;
|
|
72
81
|
return [
|
|
73
82
|
{
|
|
74
|
-
...({ touchAction: "manipulation", cursor: disabled ? "auto" : "pointer", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
|
|
83
|
+
...({ touchAction: "manipulation", cursor: disabled ? "auto" : "pointer", transitionDuration: "0.1s", transitionProperty: "background-color", userSelect } as ViewStyle),
|
|
75
84
|
},
|
|
76
85
|
{
|
|
77
86
|
backgroundColor: pressed ? colors.zinc["200"] : hovered ? colors.zinc["100"] : null,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { ReactNode, useState } from "react";
|
|
2
|
+
import { Pressable, StyleProp, StyleSheet, ViewStyle } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
|
|
5
|
+
export interface PressableRowProps {
|
|
6
|
+
onPress: () => void;
|
|
7
|
+
/** The open/selected record — paints the persistent highlight. */
|
|
8
|
+
selected?: boolean;
|
|
9
|
+
/** Part of a multi-select set — paints a persistent blue tint, distinct from
|
|
10
|
+
* `selected` (the open record). The hover / open / press wash overrides it, so
|
|
11
|
+
* it's the resting state of a ticked row in a bulk-select register. */
|
|
12
|
+
marked?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* - "bleed" (default): the standalone REGISTER row — px-20, square wash to
|
|
15
|
+
* the card edges, separated by full-bleed `Divider`s. The one pattern for
|
|
16
|
+
* top-level record lists.
|
|
17
|
+
* - "inset": a rounded, contained row — the wash is a rounded pill pulled in
|
|
18
|
+
* from the edge (radius 8, marginH -8). Lives inside a padded container (an
|
|
19
|
+
* `Accordion`'s drill-down rows, or a gap-separated selectable list) so the
|
|
20
|
+
* wash insets from the card edge instead of bleeding to it. Use when rows are
|
|
21
|
+
* GAP-separated rather than `Divider`-separated.
|
|
22
|
+
*/
|
|
23
|
+
variant?: "bleed" | "inset";
|
|
24
|
+
/** Cells + trailing controls. Nested pressables (CTAs, checkboxes, ⋯)
|
|
25
|
+
* claim their own presses; put the accessible door inside — a plain
|
|
26
|
+
* `Pressable` with role="button" + "Open …" label around the row body. */
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
/** Layout only (gap, minHeight overrides). The surface owns its press
|
|
29
|
+
* states — never pass backgroundColor for hover/selected. */
|
|
30
|
+
style?: StyleProp<ViewStyle>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* THE pressable-row surface for record lists: a role-less, non-focusable press
|
|
35
|
+
* target whose hover wash spans the ENTIRE row — including over nested controls
|
|
36
|
+
* (CTAs, ⋯, checkboxes) — because it tracks hover via DOM mouseenter/mouseleave
|
|
37
|
+
* rather than Pressability hover (react-native-web releases a parent
|
|
38
|
+
* pressable's hover to the innermost nested pressable, which would stop the
|
|
39
|
+
* wash short of trailing controls, and is why this can't be hand-rolled from
|
|
40
|
+
* `PressableHighlight`). The surface carries no button role (a button must not
|
|
41
|
+
* contain interactive descendants); the row body inside is the accessible door.
|
|
42
|
+
* `variant` picks bleed (registers) or inset (Accordion drill-down rows).
|
|
43
|
+
*/
|
|
44
|
+
export function PressableRow(props: PressableRowProps) {
|
|
45
|
+
const { onPress, selected = false, marked = false, variant = "bleed", children, style } = props;
|
|
46
|
+
const [hovered, setHovered] = useState(false);
|
|
47
|
+
|
|
48
|
+
// RN's types don't declare mouse handlers; react-native-web forwards them
|
|
49
|
+
// to the DOM element (same boundary cast PressableHighlight uses).
|
|
50
|
+
const mouseProps = {
|
|
51
|
+
onMouseEnter: () => setHovered(true),
|
|
52
|
+
onMouseLeave: () => setHovered(false),
|
|
53
|
+
} as object;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Pressable
|
|
57
|
+
onPress={onPress}
|
|
58
|
+
focusable={false}
|
|
59
|
+
{...mouseProps}
|
|
60
|
+
style={({ pressed }) => [
|
|
61
|
+
styles.row,
|
|
62
|
+
variant === "inset" ? styles.inset : styles.bleed,
|
|
63
|
+
{
|
|
64
|
+
backgroundColor: pressed ? colors.zinc[200] : selected ? colors.zinc[100] : hovered ? colors.zinc[100] : marked ? colors.blue[50] : undefined,
|
|
65
|
+
},
|
|
66
|
+
style,
|
|
67
|
+
]}
|
|
68
|
+
>
|
|
69
|
+
{children}
|
|
70
|
+
</Pressable>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const styles = StyleSheet.create({
|
|
75
|
+
row: {
|
|
76
|
+
flexDirection: "row",
|
|
77
|
+
alignItems: "center",
|
|
78
|
+
gap: 12,
|
|
79
|
+
...({ cursor: "pointer", userSelect: "none", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
|
|
80
|
+
},
|
|
81
|
+
// Square, full-bleed — lines up with the Dividers between register rows.
|
|
82
|
+
bleed: {
|
|
83
|
+
paddingHorizontal: 20,
|
|
84
|
+
},
|
|
85
|
+
// Rounded, pulled in from the edge — matches the Accordion's inset rows.
|
|
86
|
+
inset: {
|
|
87
|
+
borderRadius: 8,
|
|
88
|
+
paddingHorizontal: 8,
|
|
89
|
+
marginHorizontal: -8,
|
|
90
|
+
},
|
|
91
|
+
});
|