@lotics/ui 8.0.0 → 10.0.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/AGENTS.md +177 -70
- package/examples/tpl_allocate.tsx +2 -2
- package/examples/tpl_attendance.tsx +2 -2
- package/examples/tpl_calendar.tsx +1 -1
- package/examples/tpl_dashboard.tsx +1 -1
- package/examples/tpl_item_list.tsx +1015 -124
- package/examples/tpl_pick.tsx +3 -3
- package/examples/tpl_pivot.tsx +1 -1
- package/examples/tpl_record.tsx +1354 -0
- package/examples/tpl_report.tsx +7 -7
- package/examples/tpl_rollup.tsx +6 -6
- package/examples/tpl_shifts.tsx +2 -2
- package/examples/tpl_statements.tsx +221 -0
- package/examples/tpl_stock.tsx +7 -7
- package/examples/tpl_task_board.tsx +16 -13
- package/examples/tpl_tasks.tsx +15 -28
- package/examples/tpl_tower.tsx +2 -2
- package/package.json +8 -7
- package/src/capture_row.tsx +59 -0
- package/src/checklist.tsx +104 -0
- package/src/chip.tsx +12 -3
- package/src/detail_row.tsx +137 -10
- package/src/inline_date_picker.tsx +8 -3
- package/src/inline_edit.tsx +40 -10
- package/src/inline_member_select.tsx +3 -0
- package/src/inline_number_input.tsx +5 -2
- package/src/inline_select.tsx +8 -3
- package/src/inline_tag_select.tsx +140 -0
- package/src/inline_text_input.tsx +5 -2
- package/src/inline_time_picker.tsx +5 -2
- package/src/ledger.tsx +220 -0
- package/src/locale.tsx +25 -0
- package/src/page_header.tsx +0 -2
- package/src/popover_nav.tsx +40 -0
- package/src/progress_bar.tsx +32 -1
- package/src/record_summary.tsx +101 -0
- package/src/section_heading.tsx +16 -8
- package/src/suggestion_chip.tsx +47 -0
- package/src/trend_footer.tsx +3 -1
- package/src/use_screen_size.ts +1 -1
- package/src/use_section_nav.test.ts +69 -0
- package/src/use_section_nav.ts +59 -0
- package/examples/tpl_billing.tsx +0 -344
- package/examples/tpl_detail.tsx +0 -232
- package/examples/tpl_directory.tsx +0 -260
- package/examples/tpl_intake.tsx +0 -206
- package/examples/tpl_order.tsx +0 -482
- package/examples/tpl_quick.tsx +0 -211
- package/examples/tpl_record_plain.tsx +0 -259
- package/examples/tpl_settings.tsx +0 -178
- package/examples/tpl_timeline.tsx +0 -244
- package/examples/tpl_wizard.tsx +0 -223
- package/src/animation_horizontal_slide.tsx +0 -75
- package/src/form_time_picker.tsx +0 -22
- package/src/highlighted_text.tsx +0 -92
- package/src/menu_title.tsx +0 -15
- package/src/pager_view.tsx +0 -167
- package/src/popover_header.tsx +0 -38
package/src/inline_select.tsx
CHANGED
|
@@ -7,7 +7,8 @@ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
|
7
7
|
import { OptionList } from "./option_list";
|
|
8
8
|
import type { PickerOption } from "./picker";
|
|
9
9
|
import { ActivityIndicator } from "./activity_indicator";
|
|
10
|
-
import { InlineEditView } from "./inline_edit";
|
|
10
|
+
import { type InlineEditBackground, InlineEditView } from "./inline_edit";
|
|
11
|
+
import { useLoticsLocale } from "./locale";
|
|
11
12
|
|
|
12
13
|
export interface InlineSelectProps<T extends string> {
|
|
13
14
|
value: T | null;
|
|
@@ -24,6 +25,8 @@ export interface InlineSelectProps<T extends string> {
|
|
|
24
25
|
placeholder?: string;
|
|
25
26
|
disabled?: boolean;
|
|
26
27
|
accessibilityLabel?: string;
|
|
28
|
+
/** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
|
|
29
|
+
background?: InlineEditBackground;
|
|
27
30
|
/** Show an in-menu search box; default false. */
|
|
28
31
|
searchable?: boolean;
|
|
29
32
|
}
|
|
@@ -36,7 +39,8 @@ export interface InlineSelectProps<T extends string> {
|
|
|
36
39
|
* Pass `renderOptionContent` for rich options, or omit it for a plain label list.
|
|
37
40
|
*/
|
|
38
41
|
export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
39
|
-
const { value, onSave, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false } = props;
|
|
42
|
+
const { value, onSave, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, background } = props;
|
|
43
|
+
const labels = useLoticsLocale().inline;
|
|
40
44
|
const [open, setOpen] = useState(false);
|
|
41
45
|
const [saving, setSaving] = useState(false);
|
|
42
46
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -52,7 +56,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
|
52
56
|
try {
|
|
53
57
|
await onSave(next);
|
|
54
58
|
} catch (e) {
|
|
55
|
-
setError(e instanceof Error && e.message ? e.message :
|
|
59
|
+
setError(e instanceof Error && e.message ? e.message : labels.saveError);
|
|
56
60
|
} finally {
|
|
57
61
|
setSaving(false);
|
|
58
62
|
}
|
|
@@ -65,6 +69,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
|
65
69
|
<Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
|
|
66
70
|
<PopoverTrigger>
|
|
67
71
|
<InlineEditView
|
|
72
|
+
background={background}
|
|
68
73
|
display={selected ? (renderSelected ? renderSelected(selected) : renderOptionContent ? renderOptionContent(selected) : (selected.label ?? "")) : ""}
|
|
69
74
|
placeholder={placeholder}
|
|
70
75
|
disabled={disabled}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { Icon } from "./icon";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { colors } from "./colors";
|
|
6
|
+
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
7
|
+
import { OptionList } from "./option_list";
|
|
8
|
+
import type { PickerOption } from "./picker";
|
|
9
|
+
import { ActivityIndicator } from "./activity_indicator";
|
|
10
|
+
import { Badge } from "./badge";
|
|
11
|
+
import { type InlineEditBackground, InlineEditView } from "./inline_edit";
|
|
12
|
+
import { useLoticsLocale } from "./locale";
|
|
13
|
+
|
|
14
|
+
export interface InlineTagSelectProps<T extends string> {
|
|
15
|
+
/** The selected tag values. */
|
|
16
|
+
value: T[];
|
|
17
|
+
/** Commits the NEW SET when the picker closes (only if it changed). Throwing
|
|
18
|
+
* surfaces the inline error, like every inline editor. */
|
|
19
|
+
onSave: (next: T[]) => void | Promise<void>;
|
|
20
|
+
options: PickerOption<T>[];
|
|
21
|
+
/** The resting render of ONE selected tag (an `OptionBadge` dot, a `Chip`).
|
|
22
|
+
* Defaults to a plain zinc `Badge` of the label. */
|
|
23
|
+
renderTag?: (option: PickerOption<T>) => React.ReactNode;
|
|
24
|
+
/** Rich row content in the dropdown. Falls back to `renderTag`, then label. */
|
|
25
|
+
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
accessibilityLabel?: string;
|
|
29
|
+
/** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
|
|
30
|
+
background?: InlineEditBackground;
|
|
31
|
+
/** Show an in-menu search box; default false. */
|
|
32
|
+
searchable?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The inline-editable MULTI select — the tag-field member of the `Inline*`
|
|
37
|
+
* family (`InlineSelect` picks one; this holds a set). At rest the selected
|
|
38
|
+
* tags render as badges inside the standard inline chip; clicking floats a
|
|
39
|
+
* multi `OptionList` (checkbox rows) in a popover, toggles edit a local draft,
|
|
40
|
+
* and CLOSING the popover commits the new set in one `onSave` — the blur-commit
|
|
41
|
+
* contract of the inline family, applied to a set.
|
|
42
|
+
*/
|
|
43
|
+
export function InlineTagSelect<T extends string>(props: InlineTagSelectProps<T>) {
|
|
44
|
+
const {
|
|
45
|
+
value, onSave, options, renderTag, renderOptionContent, placeholder, disabled, accessibilityLabel, background, searchable = false,
|
|
46
|
+
} = props;
|
|
47
|
+
const labels = useLoticsLocale().inline;
|
|
48
|
+
const [open, setOpenState] = useState(false);
|
|
49
|
+
const [draft, setDraft] = useState<T[]>(value);
|
|
50
|
+
const [saving, setSaving] = useState(false);
|
|
51
|
+
const [error, setError] = useState<string | null>(null);
|
|
52
|
+
|
|
53
|
+
const commit = async (next: T[]) => {
|
|
54
|
+
const changed = next.length !== value.length || next.some((v) => !value.includes(v));
|
|
55
|
+
if (!changed) return;
|
|
56
|
+
setSaving(true);
|
|
57
|
+
setError(null);
|
|
58
|
+
try {
|
|
59
|
+
await onSave(next);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
setError(e instanceof Error && e.message ? e.message : labels.saveError);
|
|
62
|
+
} finally {
|
|
63
|
+
setSaving(false);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const setOpen = (next: boolean) => {
|
|
68
|
+
if (next) {
|
|
69
|
+
setDraft(value);
|
|
70
|
+
setOpenState(true);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
setOpenState(false);
|
|
74
|
+
void commit(draft);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const selected = options.filter((o) => value.includes(o.value));
|
|
78
|
+
const tag = (o: PickerOption<T>) => (renderTag ? renderTag(o) : <Badge label={o.label ?? String(o.value)} color="zinc" />);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<View>
|
|
82
|
+
<Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
|
|
83
|
+
<PopoverTrigger>
|
|
84
|
+
<InlineEditView
|
|
85
|
+
background={background}
|
|
86
|
+
display={
|
|
87
|
+
selected.length > 0 ? (
|
|
88
|
+
<View style={styles.tags}>
|
|
89
|
+
{selected.map((o) => (
|
|
90
|
+
<View key={o.value}>{tag(o)}</View>
|
|
91
|
+
))}
|
|
92
|
+
</View>
|
|
93
|
+
) : (
|
|
94
|
+
""
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
placeholder={placeholder}
|
|
98
|
+
disabled={disabled}
|
|
99
|
+
active={open && !disabled}
|
|
100
|
+
accessibilityLabel={accessibilityLabel}
|
|
101
|
+
trailing={
|
|
102
|
+
saving ? (
|
|
103
|
+
<ActivityIndicator size={16} color={colors.zinc[400]} />
|
|
104
|
+
) : (
|
|
105
|
+
<Icon name="chevron-down" size={18} color={colors.zinc[400]} />
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
/>
|
|
109
|
+
</PopoverTrigger>
|
|
110
|
+
<PopoverContent disableBodyScroll>
|
|
111
|
+
<OptionList<T, true>
|
|
112
|
+
multi
|
|
113
|
+
search={{ mode: searchable ? "internal" : "none" }}
|
|
114
|
+
options={options}
|
|
115
|
+
value={draft}
|
|
116
|
+
onValueChange={setDraft}
|
|
117
|
+
onRequestClose={() => setOpen(false)}
|
|
118
|
+
renderOptionContent={renderOptionContent ?? renderTag}
|
|
119
|
+
/>
|
|
120
|
+
</PopoverContent>
|
|
121
|
+
</Popover>
|
|
122
|
+
{error ? (
|
|
123
|
+
<Text size="xs" color="danger" style={styles.error}>
|
|
124
|
+
{error}
|
|
125
|
+
</Text>
|
|
126
|
+
) : null}
|
|
127
|
+
</View>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const styles = StyleSheet.create({
|
|
132
|
+
tags: {
|
|
133
|
+
flexDirection: "row",
|
|
134
|
+
alignItems: "center",
|
|
135
|
+
flexWrap: "wrap",
|
|
136
|
+
columnGap: 4,
|
|
137
|
+
rowGap: 2,
|
|
138
|
+
},
|
|
139
|
+
error: { marginTop: 4 },
|
|
140
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { NativeSyntheticEvent, TextInputKeyPressEventData } from "react-native";
|
|
3
3
|
import { TextInputField } from "./text_input_field";
|
|
4
|
-
import { InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
4
|
+
import { type InlineEditBackground, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
5
5
|
|
|
6
6
|
export interface InlineTextInputProps {
|
|
7
7
|
value: string;
|
|
@@ -15,6 +15,8 @@ export interface InlineTextInputProps {
|
|
|
15
15
|
disabled?: boolean;
|
|
16
16
|
/** Strike + mute the resting value (a completed item that stays editable). */
|
|
17
17
|
struck?: boolean;
|
|
18
|
+
/** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
|
|
19
|
+
background?: InlineEditBackground;
|
|
18
20
|
accessibilityLabel?: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -25,7 +27,7 @@ export interface InlineTextInputProps {
|
|
|
25
27
|
* value in a dense record / detail surface.
|
|
26
28
|
*/
|
|
27
29
|
export function InlineTextInput(props: InlineTextInputProps) {
|
|
28
|
-
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel } = props;
|
|
30
|
+
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , background } = props;
|
|
29
31
|
const edit = useInlineEdit<string>({ value, onSave });
|
|
30
32
|
|
|
31
33
|
const onKeyPress = useCallback(
|
|
@@ -47,6 +49,7 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
47
49
|
|
|
48
50
|
return (
|
|
49
51
|
<InlineEditFrame
|
|
52
|
+
background={background}
|
|
50
53
|
editing={edit.editing}
|
|
51
54
|
display={value}
|
|
52
55
|
placeholder={placeholder}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { KeyboardEvent } from "react";
|
|
3
3
|
import { TimePicker } from "./time_picker";
|
|
4
|
-
import { InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
4
|
+
import { type InlineEditBackground, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
5
5
|
|
|
6
6
|
export interface InlineTimePickerProps {
|
|
7
7
|
/** Canonical 24-hour "HH:mm", "" when empty. */
|
|
@@ -13,6 +13,8 @@ export interface InlineTimePickerProps {
|
|
|
13
13
|
controls?: InlineEditControls;
|
|
14
14
|
disabled?: boolean;
|
|
15
15
|
accessibilityLabel?: string;
|
|
16
|
+
/** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
|
|
17
|
+
background?: InlineEditBackground;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -21,7 +23,7 @@ export interface InlineTimePickerProps {
|
|
|
21
23
|
* field at the same height, so the form never reflows.
|
|
22
24
|
*/
|
|
23
25
|
export function InlineTimePicker(props: InlineTimePickerProps) {
|
|
24
|
-
const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel } = props;
|
|
26
|
+
const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel , background } = props;
|
|
25
27
|
const edit = useInlineEdit<string>({ value, onSave });
|
|
26
28
|
|
|
27
29
|
const onKeyDown = useCallback(
|
|
@@ -39,6 +41,7 @@ export function InlineTimePicker(props: InlineTimePickerProps) {
|
|
|
39
41
|
|
|
40
42
|
return (
|
|
41
43
|
<InlineEditFrame
|
|
44
|
+
background={background}
|
|
42
45
|
editing={edit.editing}
|
|
43
46
|
display={value}
|
|
44
47
|
placeholder={placeholder}
|
package/src/ledger.tsx
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { Divider } from "./divider";
|
|
5
|
+
import { Link } from "./link";
|
|
6
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
7
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
8
|
+
import { useLoticsLocale } from "./locale";
|
|
9
|
+
import { Text } from "./text";
|
|
10
|
+
|
|
11
|
+
// The record-level money list — a compact financial STATEMENT for one record's
|
|
12
|
+
// drawer/section: charge/receipt GROUPS with their sums in the group headers,
|
|
13
|
+
// every figure on ONE right-aligned tabular column, closed by a divider-set
|
|
14
|
+
// emphasized total. A row with `peek` is a door: pressing it floats the fee's
|
|
15
|
+
// PARTICULARS (basis, who charged it, the invoice it landed on) in an anchored
|
|
16
|
+
// popover — the list stays flat and scannable, the depth is on demand. A row
|
|
17
|
+
// with `reference` carries a trailing link instead (an issued invoice, a
|
|
18
|
+
// receipt no.). No bars, no charts — the numbers are the interface.
|
|
19
|
+
//
|
|
20
|
+
// <Ledger formatValue={formatMoney}>
|
|
21
|
+
// <LedgerGroup label="Charges" total={total}>
|
|
22
|
+
// <LedgerRow label="Service fee" value={fee} peek={<FeeParticulars … />} />
|
|
23
|
+
// <LedgerRow label="Levy" value={levy} reference={{ label: "INV-0414", onPress }} />
|
|
24
|
+
// </LedgerGroup>
|
|
25
|
+
// <LedgerGroup label="Received" total={-received}>
|
|
26
|
+
// <LedgerRow label="Deposit" meta="03/06 · Cash" value={-1_550_000} tone="success" />
|
|
27
|
+
// </LedgerGroup>
|
|
28
|
+
// <LedgerTotal label="Outstanding" value={due} tone="danger" zeroLabel="Paid in full" />
|
|
29
|
+
// </Ledger>
|
|
30
|
+
|
|
31
|
+
interface LedgerContextValue {
|
|
32
|
+
format: (n: number) => string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const LedgerContext = createContext<LedgerContextValue | null>(null);
|
|
36
|
+
|
|
37
|
+
function useLedger(): LedgerContextValue {
|
|
38
|
+
const ctx = useContext(LedgerContext);
|
|
39
|
+
if (!ctx) throw new Error("Ledger components must be used within a Ledger");
|
|
40
|
+
return ctx;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Signed display: negatives render as "− <abs>" (a receipt against charges).
|
|
44
|
+
* `n + 0` normalizes -0 (a zero receipts sum passed negated) to plain 0. */
|
|
45
|
+
function signed(format: (n: number) => string, n: number) {
|
|
46
|
+
return n < 0 ? `− ${format(-n)}` : format(n + 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LedgerProps {
|
|
50
|
+
/** Money formatting shared by every row, sum, and total (e.g. `formatMoney`). */
|
|
51
|
+
formatValue: (n: number) => string;
|
|
52
|
+
children: ReactNode;
|
|
53
|
+
style?: StyleProp<ViewStyle>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function Ledger(props: LedgerProps) {
|
|
57
|
+
const { formatValue, children, style } = props;
|
|
58
|
+
return (
|
|
59
|
+
<LedgerContext.Provider value={{ format: formatValue }}>
|
|
60
|
+
<View style={[styles.ledger, style]}>{children}</View>
|
|
61
|
+
</LedgerContext.Provider>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface LedgerGroupProps {
|
|
66
|
+
/** The group name ("Charges", "Received") — xs muted, with the sum opposite. */
|
|
67
|
+
label: string;
|
|
68
|
+
/** The group's sum, shown right-aligned in the header. Sign renders as
|
|
69
|
+
* "−" (pass receipts negative). Omit to leave the header sumless. */
|
|
70
|
+
total?: number;
|
|
71
|
+
children: ReactNode;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One side of the ledger — a labelled run of rows with its sum in the header. */
|
|
75
|
+
export function LedgerGroup(props: LedgerGroupProps) {
|
|
76
|
+
const { label, total, children } = props;
|
|
77
|
+
const { format } = useLedger();
|
|
78
|
+
return (
|
|
79
|
+
<View style={styles.group}>
|
|
80
|
+
<View style={styles.row}>
|
|
81
|
+
<Text size="xs" weight="medium" color="muted" style={styles.grow}>
|
|
82
|
+
{label}
|
|
83
|
+
</Text>
|
|
84
|
+
{total != null ? (
|
|
85
|
+
<Text size="xs" color="muted" tabular>
|
|
86
|
+
{signed(format, total)}
|
|
87
|
+
</Text>
|
|
88
|
+
) : null}
|
|
89
|
+
</View>
|
|
90
|
+
{children}
|
|
91
|
+
</View>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface LedgerRowProps {
|
|
96
|
+
/** The line's name — what was charged / received. */
|
|
97
|
+
label: string;
|
|
98
|
+
/** Inline context after the label — a date · method, a basis, a period. */
|
|
99
|
+
meta?: string;
|
|
100
|
+
/** The amount. Negative renders "− <abs>" (a receipt). */
|
|
101
|
+
value: number;
|
|
102
|
+
/** Valence of the amount (a receipt reads success). */
|
|
103
|
+
tone?: "default" | "success" | "danger";
|
|
104
|
+
/** The line's PARTICULARS — set it and the whole row becomes a pressable
|
|
105
|
+
* door that floats this node in an anchored popover. Put any links (the
|
|
106
|
+
* invoice, the tariff) INSIDE the peek: a row is never a button holding
|
|
107
|
+
* another button, so `reference` is ignored while `peek` is set. */
|
|
108
|
+
peek?: ReactNode;
|
|
109
|
+
/** Peek popover width. Default 300. */
|
|
110
|
+
peekWidth?: number;
|
|
111
|
+
/** A trailing reference link (an issued invoice, a receipt no.) for a row
|
|
112
|
+
* WITHOUT `peek`. */
|
|
113
|
+
reference?: { label: string; onPress: () => void };
|
|
114
|
+
/** Accessible name for a peekable row. Defaults to "<label> details". */
|
|
115
|
+
accessibilityLabel?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** One money line. Static by default; a door to its particulars with `peek`. */
|
|
119
|
+
export function LedgerRow(props: LedgerRowProps) {
|
|
120
|
+
const { label, meta, value, tone = "default", peek, peekWidth = 300, reference, accessibilityLabel } = props;
|
|
121
|
+
const rowDetails = useLoticsLocale().ledger.rowDetails;
|
|
122
|
+
const { format } = useLedger();
|
|
123
|
+
const content = (
|
|
124
|
+
<>
|
|
125
|
+
<Text size="sm" numberOfLines={1} style={styles.shrink}>
|
|
126
|
+
{label}
|
|
127
|
+
</Text>
|
|
128
|
+
{meta ? (
|
|
129
|
+
<Text size="xs" color="muted" numberOfLines={1}>
|
|
130
|
+
{meta}
|
|
131
|
+
</Text>
|
|
132
|
+
) : null}
|
|
133
|
+
<View style={styles.grow} />
|
|
134
|
+
{!peek && reference ? (
|
|
135
|
+
<Link size="xs" onPress={reference.onPress} accessibilityLabel={reference.label}>
|
|
136
|
+
{reference.label}
|
|
137
|
+
</Link>
|
|
138
|
+
) : null}
|
|
139
|
+
<Text size="sm" tabular color={tone === "default" ? undefined : tone}>
|
|
140
|
+
{signed(format, value)}
|
|
141
|
+
</Text>
|
|
142
|
+
</>
|
|
143
|
+
);
|
|
144
|
+
if (!peek) {
|
|
145
|
+
return <View style={styles.row}>{content}</View>;
|
|
146
|
+
}
|
|
147
|
+
return (
|
|
148
|
+
<Popover side="bottom" align="end">
|
|
149
|
+
<PopoverTrigger>
|
|
150
|
+
<PressableHighlight
|
|
151
|
+
focusRing
|
|
152
|
+
accessibilityRole="button"
|
|
153
|
+
accessibilityLabel={accessibilityLabel ?? rowDetails(label)}
|
|
154
|
+
style={(state) => [styles.row, styles.rowPress, state.hovered ? styles.rowHovered : null]}
|
|
155
|
+
>
|
|
156
|
+
{content}
|
|
157
|
+
</PressableHighlight>
|
|
158
|
+
</PopoverTrigger>
|
|
159
|
+
<PopoverContent style={{ width: peekWidth }} disableBodyScroll>
|
|
160
|
+
{peek}
|
|
161
|
+
</PopoverContent>
|
|
162
|
+
</Popover>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface LedgerTotalProps {
|
|
167
|
+
/** The closing question the ledger answers ("Outstanding", "Balance"). */
|
|
168
|
+
label: string;
|
|
169
|
+
value: number;
|
|
170
|
+
/** Valence of the closing figure. */
|
|
171
|
+
tone?: "default" | "success" | "danger";
|
|
172
|
+
/** Rendered instead of the figure when `value` is 0 ("Paid in full") — in
|
|
173
|
+
* success tone, since a settled ledger is the good outcome. */
|
|
174
|
+
zeroLabel?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The divider-set closing line — the ledger's ONE emphasized number. */
|
|
178
|
+
export function LedgerTotal(props: LedgerTotalProps) {
|
|
179
|
+
const { label, value, tone = "default", zeroLabel } = props;
|
|
180
|
+
const { format } = useLedger();
|
|
181
|
+
const settled = value === 0 && zeroLabel != null;
|
|
182
|
+
return (
|
|
183
|
+
<View style={styles.total}>
|
|
184
|
+
<Divider />
|
|
185
|
+
<View style={styles.row}>
|
|
186
|
+
<Text size="sm" weight="semibold" style={styles.grow}>
|
|
187
|
+
{label}
|
|
188
|
+
</Text>
|
|
189
|
+
<Text size="lg" weight="semibold" tabular color={settled ? "success" : tone === "default" ? undefined : tone}>
|
|
190
|
+
{settled ? zeroLabel : signed(format, value)}
|
|
191
|
+
</Text>
|
|
192
|
+
</View>
|
|
193
|
+
</View>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const styles = StyleSheet.create({
|
|
198
|
+
// Outdent by the rows' 8px inset (the ListItem/Timeline technique) so row text +
|
|
199
|
+
// the money column align with the section heading and the container edges;
|
|
200
|
+
// the pressable door's wash bleeds into the gutter instead of squeezing text.
|
|
201
|
+
ledger: { gap: 12, marginHorizontal: -8 },
|
|
202
|
+
group: { gap: 4 },
|
|
203
|
+
// EVERY line (group header, rows, the total) shares the 8px text inset, so
|
|
204
|
+
// labels and the money column sit on one edge whether a row peeks or not —
|
|
205
|
+
// the pressable door's wash simply fills the same padded box.
|
|
206
|
+
row: {
|
|
207
|
+
flexDirection: "row",
|
|
208
|
+
alignItems: "center",
|
|
209
|
+
gap: 10,
|
|
210
|
+
minHeight: 28,
|
|
211
|
+
paddingHorizontal: 8,
|
|
212
|
+
},
|
|
213
|
+
rowPress: {
|
|
214
|
+
borderRadius: 8,
|
|
215
|
+
},
|
|
216
|
+
rowHovered: { backgroundColor: colors.zinc[50] },
|
|
217
|
+
grow: { flexGrow: 1, flexShrink: 1 },
|
|
218
|
+
shrink: { flexShrink: 1 },
|
|
219
|
+
total: { gap: 6 },
|
|
220
|
+
});
|
package/src/locale.tsx
CHANGED
|
@@ -36,6 +36,19 @@ export interface LoticsLocale {
|
|
|
36
36
|
formField: { optional: string };
|
|
37
37
|
/** `Drawer`: the record prev/next + close controls (screen-reader names). */
|
|
38
38
|
drawer: { previous: string; next: string; close: string };
|
|
39
|
+
/** The `Inline*` editor family: the shared save-error line and the
|
|
40
|
+
* text-editor Save/Cancel tooltips. */
|
|
41
|
+
inline: { saveError: string; save: string; cancel: string };
|
|
42
|
+
/** `Ledger`: the screen-reader name of a peekable row. */
|
|
43
|
+
ledger: { rowDetails: (label: string) => string };
|
|
44
|
+
/** `SectionHeadingTitle`: the info-popover trigger's screen-reader name. */
|
|
45
|
+
sectionHeading: { info: string };
|
|
46
|
+
/** `Chip`: the ✕ default name when no `dismissTooltip` is given. */
|
|
47
|
+
chip: { remove: string };
|
|
48
|
+
/** `TrendFooter`: the direction words of the trend sentence. */
|
|
49
|
+
trendFooter: { up: string; down: string };
|
|
50
|
+
/** `SuggestionChip`: the press target's default name and the ✕ tooltip. */
|
|
51
|
+
suggestionChip: { add: (label: string) => string; dismiss: string };
|
|
39
52
|
/** `Confidence`: the full level phrase ("High confidence" …). */
|
|
40
53
|
confidence: ConfidenceLabels;
|
|
41
54
|
/** `Finding`: the severity badge word ("Critical" …). */
|
|
@@ -90,6 +103,12 @@ export const en: LoticsLocale = {
|
|
|
90
103
|
filterChip: { clear: "Clear" },
|
|
91
104
|
formField: { optional: "Optional" },
|
|
92
105
|
drawer: { previous: "Previous record", next: "Next record", close: "Close" },
|
|
106
|
+
inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
|
|
107
|
+
ledger: { rowDetails: (label) => `${label} details` },
|
|
108
|
+
sectionHeading: { info: "About this data" },
|
|
109
|
+
chip: { remove: "Remove" },
|
|
110
|
+
trendFooter: { up: "Up", down: "Down" },
|
|
111
|
+
suggestionChip: { add: (label) => `Add: ${label}`, dismiss: "Dismiss suggestion" },
|
|
93
112
|
confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
|
|
94
113
|
remainderMeter: {
|
|
95
114
|
applied: (allocated, total) => `${allocated} of ${total} applied`,
|
|
@@ -161,6 +180,12 @@ export const vi: LoticsLocale = {
|
|
|
161
180
|
filterChip: { clear: "Xóa" },
|
|
162
181
|
formField: { optional: "Tùy chọn" },
|
|
163
182
|
drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
|
|
183
|
+
inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
|
|
184
|
+
ledger: { rowDetails: (label) => `Chi tiết ${label}` },
|
|
185
|
+
sectionHeading: { info: "Giải thích dữ liệu" },
|
|
186
|
+
chip: { remove: "Xóa" },
|
|
187
|
+
trendFooter: { up: "Tăng", down: "Giảm" },
|
|
188
|
+
suggestionChip: { add: (label) => `Thêm: ${label}`, dismiss: "Bỏ gợi ý" },
|
|
164
189
|
confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
|
|
165
190
|
remainderMeter: {
|
|
166
191
|
applied: (allocated, total) => `Đã phân bổ ${allocated}/${total}`,
|
package/src/page_header.tsx
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { View } from "react-native";
|
|
2
2
|
import { Text } from "@lotics/ui/text";
|
|
3
3
|
import { ReactNode } from "react";
|
|
4
|
-
import { useScreenSize } from "@lotics/ui/use_screen_size";
|
|
5
4
|
|
|
6
5
|
interface PageHeaderProps {
|
|
7
6
|
title: string;
|
|
@@ -12,7 +11,6 @@ interface PageHeaderProps {
|
|
|
12
11
|
|
|
13
12
|
export function PageHeader(props: PageHeaderProps) {
|
|
14
13
|
const { title, description, left, right } = props;
|
|
15
|
-
const screenSize = useScreenSize();
|
|
16
14
|
|
|
17
15
|
const hasNav = !!left || !!right;
|
|
18
16
|
|
package/src/popover_nav.tsx
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { createContext, ReactNode, useContext } from "react";
|
|
2
|
+
import { IconButton } from "./icon_button";
|
|
3
|
+
import { View, StyleSheet } from "react-native";
|
|
4
|
+
import { Text } from "./text";
|
|
2
5
|
|
|
3
6
|
export interface PopoverNavContextValue {
|
|
4
7
|
currentRoute: string;
|
|
@@ -30,3 +33,40 @@ export function PopoverScreen(props: PopoverScreenProps) {
|
|
|
30
33
|
|
|
31
34
|
return <>{children}</>;
|
|
32
35
|
}
|
|
36
|
+
|
|
37
|
+
export interface PopoverNavHeaderProps {
|
|
38
|
+
title: string;
|
|
39
|
+
right?: ReactNode;
|
|
40
|
+
/** Accessible name for the back button. Default: "Back". Pass a translated string from the consumer. */
|
|
41
|
+
backLabel?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The routed-popover title row: a back chevron auto-appears while `canGoBack`
|
|
45
|
+
* (reads `usePopoverNav`), sm-medium title fills, `right` a trailing action.
|
|
46
|
+
* Pairs with `PopoverScreen`. Distinct from `Popover`'s own plain
|
|
47
|
+
* `PopoverHeader` children container. */
|
|
48
|
+
export function PopoverNavHeader(props: PopoverNavHeaderProps) {
|
|
49
|
+
const { title, right, backLabel = "Back" } = props;
|
|
50
|
+
const { goBack, canGoBack } = usePopoverNav();
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<View style={navHeaderStyles.container}>
|
|
54
|
+
{canGoBack && (
|
|
55
|
+
<IconButton icon="chevron-left" size="lg" color="secondary" accessibilityLabel={backLabel} onPress={goBack} />
|
|
56
|
+
)}
|
|
57
|
+
<Text size="sm" weight="medium" style={{ flex: 1 }}>
|
|
58
|
+
{title}
|
|
59
|
+
</Text>
|
|
60
|
+
{right}
|
|
61
|
+
</View>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const navHeaderStyles = StyleSheet.create({
|
|
66
|
+
container: {
|
|
67
|
+
paddingLeft: 8,
|
|
68
|
+
flexDirection: "row",
|
|
69
|
+
alignItems: "center",
|
|
70
|
+
gap: 8,
|
|
71
|
+
},
|
|
72
|
+
});
|
package/src/progress_bar.tsx
CHANGED
|
@@ -16,6 +16,11 @@ export interface ProgressBarProps {
|
|
|
16
16
|
format?: ProgressBarFormat;
|
|
17
17
|
color?: string;
|
|
18
18
|
completeColor?: string;
|
|
19
|
+
/** COMPACT: one row — the track (flex) with a plain sm tabular count beside
|
|
20
|
+
* it ("4/5", or "80%" under format="percentage"). For cells, headings, and
|
|
21
|
+
* peek triggers where the stacked title-over-track anatomy is too tall and
|
|
22
|
+
* a floating caption reads misaligned. `title` is ignored. */
|
|
23
|
+
compact?: boolean;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
/**
|
|
@@ -32,11 +37,29 @@ export function ProgressBar(props: ProgressBarProps) {
|
|
|
32
37
|
format = "percentage",
|
|
33
38
|
color = colors.blue["500"],
|
|
34
39
|
completeColor = colors.green["500"],
|
|
40
|
+
compact = false,
|
|
35
41
|
} = props;
|
|
36
42
|
|
|
37
|
-
const percentage = Math.min(100, Math.max(0, (value / max) * 100));
|
|
43
|
+
const percentage = max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0;
|
|
38
44
|
const isComplete = percentage >= 100;
|
|
39
45
|
|
|
46
|
+
if (compact) {
|
|
47
|
+
const label =
|
|
48
|
+
format === "percentage" ? `${Math.round(percentage)}%` : `${value.toLocaleString("vi-VN")}/${max.toLocaleString("vi-VN")}`;
|
|
49
|
+
return (
|
|
50
|
+
<View style={styles.compactRow}>
|
|
51
|
+
<View style={[styles.track, styles.compactTrack]}>
|
|
52
|
+
<View style={[styles.fill, { width: `${percentage}%`, backgroundColor: isComplete ? completeColor : color }]} />
|
|
53
|
+
</View>
|
|
54
|
+
{format === "none" ? null : (
|
|
55
|
+
<Text size="sm" tabular color={isComplete ? "success" : "muted"}>
|
|
56
|
+
{label}
|
|
57
|
+
</Text>
|
|
58
|
+
)}
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
40
63
|
const caption =
|
|
41
64
|
format === "fraction"
|
|
42
65
|
? `${value.toLocaleString("vi-VN")} / ${max.toLocaleString("vi-VN")} · ${Math.round(percentage)}%`
|
|
@@ -80,6 +103,14 @@ const styles = StyleSheet.create({
|
|
|
80
103
|
container: {
|
|
81
104
|
gap: 6,
|
|
82
105
|
},
|
|
106
|
+
compactRow: {
|
|
107
|
+
flexDirection: "row",
|
|
108
|
+
alignItems: "center",
|
|
109
|
+
gap: 8,
|
|
110
|
+
},
|
|
111
|
+
compactTrack: {
|
|
112
|
+
flex: 1,
|
|
113
|
+
},
|
|
83
114
|
header: {
|
|
84
115
|
flexDirection: "row",
|
|
85
116
|
alignItems: "baseline",
|