@lotics/ui 7.19.3 → 9.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 +298 -159
- 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_dieline.tsx +3 -3
- package/examples/tpl_documents.tsx +790 -0
- package/examples/tpl_item_list.tsx +1015 -124
- package/examples/tpl_lookup.tsx +37 -10
- 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 +48 -32
- package/examples/tpl_tasks.tsx +47 -47
- package/examples/tpl_tower.tsx +2 -2
- package/package.json +9 -9
- package/src/agent_run.tsx +1 -1
- package/src/capture_row.tsx +59 -0
- package/src/change_review.tsx +732 -236
- package/src/checklist.tsx +104 -0
- package/src/chip.tsx +12 -3
- package/src/composer.tsx +22 -1
- package/src/confidence.tsx +1 -1
- package/src/control_surface.ts +0 -14
- package/src/detail_row.tsx +137 -10
- package/src/finding.tsx +112 -80
- package/src/floating_action_bar.tsx +1 -1
- 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 +43 -16
- package/src/progress_bar.tsx +32 -1
- package/src/record_summary.tsx +101 -0
- package/src/section_heading.tsx +16 -8
- package/src/sources.tsx +8 -5
- package/src/suggestion_chip.tsx +47 -0
- package/src/use_section_nav.test.ts +69 -0
- package/src/use_section_nav.ts +59 -0
- package/examples/tpl_assistant.tsx +0 -174
- package/examples/tpl_billing.tsx +0 -344
- package/examples/tpl_briefing.tsx +0 -121
- package/examples/tpl_compare.tsx +0 -133
- package/examples/tpl_crosscheck.tsx +0 -120
- package/examples/tpl_detail.tsx +0 -232
- package/examples/tpl_directory.tsx +0 -260
- package/examples/tpl_draft.tsx +0 -163
- package/examples/tpl_extract.tsx +0 -193
- package/examples/tpl_intake.tsx +0 -206
- package/examples/tpl_match.tsx +0 -134
- package/examples/tpl_order.tsx +0 -482
- package/examples/tpl_quick.tsx +0 -211
- package/examples/tpl_ratedesk.tsx +0 -386
- 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_triage.tsx +0 -112
- package/examples/tpl_wizard.tsx +0 -223
- package/src/discrepancy.tsx +0 -114
- package/src/match_sides.tsx +0 -79
- package/src/record_fields.tsx +0 -68
- package/src/review_card.tsx +0 -172
- package/src/scored_option.tsx +0 -138
- package/src/spec_list.tsx +0 -81
- package/src/triage_row.tsx +0 -99
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { ActionMenu, type ActionMenuItem } from "./action_menu";
|
|
4
|
+
|
|
5
|
+
// The record-scoped CHECKLIST — the compound that owns the list's GEOMETRY
|
|
6
|
+
// (row height, ring/title alignment, one trailing column width) while the
|
|
7
|
+
// CONTENT stays composed: a `CheckCircle` (live or read-only) in `control`,
|
|
8
|
+
// a struck `InlineTextInput` or plain `Text` as the title, an
|
|
9
|
+
// `InlineMemberSelect` or nothing in `trailing`, and `menu` for the row's
|
|
10
|
+
// ⋯ options (Delete lives BEHIND the menu — indirection that prevents
|
|
11
|
+
// accidental destructive taps). Suggested tasks are NOT rows: offer the record
|
|
12
|
+
// type's commons as `SuggestionChip`s under the list (tap = materialize,
|
|
13
|
+
// ✕ = dismiss) — a pill can't be mistaken for a task. `CaptureRow` closes the
|
|
14
|
+
// list as its add affordance. There is deliberately NO monolithic Task
|
|
15
|
+
// component — richer task-management rows (expand affordances, tag/clip meta,
|
|
16
|
+
// board cards) compose their own anatomy directly.
|
|
17
|
+
//
|
|
18
|
+
// <Checklist trailingWidth={140}>
|
|
19
|
+
// <ChecklistRow control={<CheckCircle …/>} trailing={<InlineMemberSelect …/>}
|
|
20
|
+
// menu={{ items: [{ key: "delete", label: "Delete task", danger: true, … }],
|
|
21
|
+
// accessibilityLabel: "Task options: …" }}>
|
|
22
|
+
// <InlineTextInput background="transparent" struck={done} … />
|
|
23
|
+
// </ChecklistRow>
|
|
24
|
+
// <SuggestionChip label="Verify the tax ID" onAdd={materialize} onDismiss={dismiss} />
|
|
25
|
+
// <CaptureRow … />
|
|
26
|
+
// </Checklist>
|
|
27
|
+
|
|
28
|
+
interface ChecklistContextValue {
|
|
29
|
+
trailingWidth?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ChecklistContext = createContext<ChecklistContextValue>({});
|
|
33
|
+
|
|
34
|
+
export interface ChecklistProps {
|
|
35
|
+
/** Fixed width of every row's trailing cell (an assignee select) — set it
|
|
36
|
+
* once so the column lines up; omit when rows carry no trailing. */
|
|
37
|
+
trailingWidth?: number;
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function Checklist(props: ChecklistProps) {
|
|
42
|
+
const { trailingWidth, children } = props;
|
|
43
|
+
return (
|
|
44
|
+
<ChecklistContext.Provider value={{ trailingWidth }}>
|
|
45
|
+
<View style={styles.list}>{children}</View>
|
|
46
|
+
</ChecklistContext.Provider>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ChecklistRowBaseProps {
|
|
51
|
+
/** The leading toggle — a `CheckCircle` (omit `onChange` for a read-only ring). */
|
|
52
|
+
control: ReactNode;
|
|
53
|
+
/** The title — a struck transparent `InlineTextInput`, or plain `Text`. */
|
|
54
|
+
children: ReactNode;
|
|
55
|
+
/** The row's ⋯ options menu (destructive items last, `danger: true`) —
|
|
56
|
+
* Delete lives HERE, never as a bare ✕ on the row, so a stray tap can't
|
|
57
|
+
* destroy a task. Name the task in the label ("Task options: Book the
|
|
58
|
+
* carrier"). The ⋯ column aligns only when every row in the list carries
|
|
59
|
+
* a menu — keep its presence uniform per list. */
|
|
60
|
+
menu?: { items: ActionMenuItem[]; accessibilityLabel: string };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `trailing` (wide surfaces) and `meta` (narrow) are exclusive by type. */
|
|
64
|
+
export type ChecklistRowProps = ChecklistRowBaseProps &
|
|
65
|
+
(
|
|
66
|
+
| {
|
|
67
|
+
/** The trailing cell (an `InlineMemberSelect`); sized by the list's `trailingWidth`. */
|
|
68
|
+
trailing?: ReactNode;
|
|
69
|
+
meta?: never;
|
|
70
|
+
}
|
|
71
|
+
| {
|
|
72
|
+
/** SECOND line under the title (indented past the ring) for a NARROW
|
|
73
|
+
* surface — a drawer or popover checklist: put the assignee/due
|
|
74
|
+
* editors here so the title keeps the full width and stays readable. */
|
|
75
|
+
meta?: ReactNode;
|
|
76
|
+
trailing?: never;
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/** One task line: control · title (flex) · the aligned trailing cell · ⋯,
|
|
81
|
+
* with an optional indented `meta` line below for narrow surfaces. */
|
|
82
|
+
export function ChecklistRow(props: ChecklistRowProps) {
|
|
83
|
+
const { control, children, trailing, menu, meta } = props;
|
|
84
|
+
const { trailingWidth } = useContext(ChecklistContext);
|
|
85
|
+
return (
|
|
86
|
+
<View>
|
|
87
|
+
<View style={styles.row}>
|
|
88
|
+
{control}
|
|
89
|
+
<View style={styles.title}>{children}</View>
|
|
90
|
+
{trailing != null ? <View style={trailingWidth != null ? { width: trailingWidth } : null}>{trailing}</View> : null}
|
|
91
|
+
{menu != null ? <ActionMenu items={menu.items} accessibilityLabel={menu.accessibilityLabel} /> : null}
|
|
92
|
+
</View>
|
|
93
|
+
{meta != null ? <View style={styles.meta}>{meta}</View> : null}
|
|
94
|
+
</View>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const styles = StyleSheet.create({
|
|
99
|
+
list: { gap: 4 },
|
|
100
|
+
row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
|
|
101
|
+
title: { flex: 1 },
|
|
102
|
+
// Indent past the ring (20) + the row gap (12) so meta aligns with the title.
|
|
103
|
+
meta: { paddingLeft: 32, paddingBottom: 4, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 2 },
|
|
104
|
+
});
|
package/src/chip.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Ref } from "react";
|
|
2
2
|
import { IconButton } from "./icon_button";
|
|
3
|
+
import { useLoticsLocale } from "./locale";
|
|
3
4
|
import { PressableHighlight } from "./pressable_highlight";
|
|
4
5
|
import { chipSurfaceStyle } from "./control_surface";
|
|
5
6
|
import { StyleSheet, View } from "react-native";
|
|
@@ -8,19 +9,27 @@ interface ChipProps {
|
|
|
8
9
|
testID?: string;
|
|
9
10
|
children: React.ReactNode;
|
|
10
11
|
onPress?: () => void;
|
|
12
|
+
/** Accessible name of the press target. The pressable pill announces as a
|
|
13
|
+
* button either way — its name derives from the children when they are
|
|
14
|
+
* self-describing text; pass this when they aren't (icon-led content). */
|
|
15
|
+
accessibilityLabel?: string;
|
|
11
16
|
onDismiss?: () => void;
|
|
12
17
|
dismissTooltip?: string;
|
|
13
18
|
ref?: Ref<View>;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
export function Chip(props: ChipProps) {
|
|
17
|
-
const { testID, children, onPress, onDismiss, dismissTooltip, ref } = props;
|
|
22
|
+
const { testID, children, onPress, accessibilityLabel, onDismiss, dismissTooltip, ref } = props;
|
|
23
|
+
const labels = useLoticsLocale().chip;
|
|
18
24
|
|
|
19
25
|
return (
|
|
20
26
|
<View ref={ref}>
|
|
21
27
|
{onPress ? (
|
|
22
28
|
<PressableHighlight
|
|
23
|
-
focusRing
|
|
29
|
+
focusRing
|
|
30
|
+
testID={testID}
|
|
31
|
+
accessibilityRole="button"
|
|
32
|
+
accessibilityLabel={accessibilityLabel}
|
|
24
33
|
onPress={onPress}
|
|
25
34
|
style={(state) => [chipSurfaceStyle(state), styles.pillLayout, onDismiss && styles.pillWithDismiss]}
|
|
26
35
|
>
|
|
@@ -34,7 +43,7 @@ export function Chip(props: ChipProps) {
|
|
|
34
43
|
<IconButton
|
|
35
44
|
icon="x"
|
|
36
45
|
tooltip={dismissTooltip}
|
|
37
|
-
accessibilityLabel={dismissTooltip ??
|
|
46
|
+
accessibilityLabel={dismissTooltip ?? labels.remove}
|
|
38
47
|
onPress={onDismiss}
|
|
39
48
|
testID={testID ? `${testID}-remove` : undefined}
|
|
40
49
|
/>
|
package/src/composer.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import React, { type ReactNode, type Ref, useCallback, useEffect, useState } fro
|
|
|
2
2
|
import { View, TextInput as RNTextInput, ScrollView, StyleSheet, type TextInputProps } from "react-native";
|
|
3
3
|
import { IconButton } from "./icon_button";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
+
import { Text } from "./text";
|
|
5
6
|
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
6
7
|
import { useAutoGrowHeight } from "./use_auto_grow_height";
|
|
7
8
|
|
|
@@ -189,7 +190,6 @@ export function Composer(props: ComposerProps) {
|
|
|
189
190
|
autoFocus={autoFocus}
|
|
190
191
|
onChangeText={handleChangeText}
|
|
191
192
|
onSubmitEditing={handleSend}
|
|
192
|
-
placeholder={placeholder}
|
|
193
193
|
placeholderTextColor={colors.zinc[500]}
|
|
194
194
|
value={text}
|
|
195
195
|
onFocus={() => setFocused(true)}
|
|
@@ -208,6 +208,15 @@ export function Composer(props: ComposerProps) {
|
|
|
208
208
|
spreadInputProps?.style,
|
|
209
209
|
]}
|
|
210
210
|
/>
|
|
211
|
+
{/* The placeholder is OURS, not the textarea's: a long hint must never
|
|
212
|
+
wrap into clipped lines inside the pill — one line, ellipsized. */}
|
|
213
|
+
{emptyText && placeholder ? (
|
|
214
|
+
<View pointerEvents="none" style={[styles.placeholderOverlay, expanded ? styles.placeholderOverlayExpanded : { paddingHorizontal: 10 }]}>
|
|
215
|
+
<Text size="sm" numberOfLines={1} style={{ color: colors.zinc[500], letterSpacing: -0.4 }}>
|
|
216
|
+
{placeholder}
|
|
217
|
+
</Text>
|
|
218
|
+
</View>
|
|
219
|
+
) : null}
|
|
211
220
|
</View>
|
|
212
221
|
);
|
|
213
222
|
|
|
@@ -314,6 +323,18 @@ const styles = StyleSheet.create({
|
|
|
314
323
|
field: {
|
|
315
324
|
flex: 1,
|
|
316
325
|
},
|
|
326
|
+
placeholderOverlay: {
|
|
327
|
+
position: "absolute",
|
|
328
|
+
left: 0,
|
|
329
|
+
right: 0,
|
|
330
|
+
top: 0,
|
|
331
|
+
bottom: 0,
|
|
332
|
+
justifyContent: "center",
|
|
333
|
+
},
|
|
334
|
+
placeholderOverlayExpanded: {
|
|
335
|
+
justifyContent: "flex-start",
|
|
336
|
+
paddingTop: 2,
|
|
337
|
+
},
|
|
317
338
|
textInput: {
|
|
318
339
|
flex: 1,
|
|
319
340
|
fontFamily: fontFamilyRegular,
|
package/src/confidence.tsx
CHANGED
|
@@ -36,7 +36,7 @@ export function levelFromScore(score: number): ConfidenceLevel {
|
|
|
36
36
|
* How sure the AI is — a calibrated high / medium / low read as a 3-tick meter
|
|
37
37
|
* (emerald / amber / zinc) beside the level word. The fill count and the colour
|
|
38
38
|
* both carry the level. The human weights an AI proposal or estimate by it. Pair
|
|
39
|
-
* with `
|
|
39
|
+
* with `ChangeReview`.
|
|
40
40
|
*/
|
|
41
41
|
export function Confidence(props: ConfidenceProps) {
|
|
42
42
|
const level = props.level ?? (props.score != null ? levelFromScore(props.score) : "medium");
|
package/src/control_surface.ts
CHANGED
|
@@ -39,20 +39,6 @@ export const CONTROL_TRANSITION = {
|
|
|
39
39
|
transitionDuration: "0.12s",
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
/** THE review-card surface — the bordered white card every "AI proposes, you
|
|
43
|
-
* decide" surface wears (`ReviewCard`, and the whole-set `ChangeReview`), so the
|
|
44
|
-
* review family reads as ONE contract instead of look-alikes drifting apart. One
|
|
45
|
-
* definition; each adds its own header / body / footer on top.
|
|
46
|
-
* `reviewResolvedStyle` is the settled wash once decided. */
|
|
47
|
-
export const reviewCardStyle: ViewStyle = {
|
|
48
|
-
borderWidth: 1,
|
|
49
|
-
borderColor: colors.border,
|
|
50
|
-
backgroundColor: colors.white,
|
|
51
|
-
borderRadius: 12,
|
|
52
|
-
padding: 16,
|
|
53
|
-
gap: 12,
|
|
54
|
-
};
|
|
55
|
-
export const reviewResolvedStyle: ViewStyle = { backgroundColor: colors.zinc[50] };
|
|
56
42
|
|
|
57
43
|
/**
|
|
58
44
|
* THE pill-surface contract — the single definition of the bordered, white,
|
package/src/detail_row.tsx
CHANGED
|
@@ -1,24 +1,107 @@
|
|
|
1
|
-
import { ReactNode } from "react";
|
|
2
|
-
import { StyleSheet, View } from "react-native";
|
|
1
|
+
import { createContext, ReactNode, useContext, useState } from "react";
|
|
2
|
+
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
|
+
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
3
4
|
import { Text } from "./text";
|
|
4
5
|
|
|
6
|
+
interface DetailTableColumns {
|
|
7
|
+
labelWidth: number;
|
|
8
|
+
trailingWidth?: number;
|
|
9
|
+
minHeight: number;
|
|
10
|
+
/** Container too narrow for the side-by-side columns — rows render STACKED
|
|
11
|
+
* (label above a full-width value row). */
|
|
12
|
+
stacked: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DetailTableContext = createContext<DetailTableColumns | null>(null);
|
|
16
|
+
|
|
17
|
+
/** The value cell must keep at least this width before the table gives up its
|
|
18
|
+
* side-by-side columns and stacks — below it, editors get crushed. */
|
|
19
|
+
const MIN_VALUE_WIDTH = 160;
|
|
20
|
+
|
|
21
|
+
export interface DetailTableProps {
|
|
22
|
+
/** Label column width (px). Default 130. */
|
|
23
|
+
labelWidth?: number;
|
|
24
|
+
/** Reserved width (px) for the trailing column. Set it when ANY row carries a
|
|
25
|
+
* trailing action/unit/badge: EVERY row then reserves the column, so all
|
|
26
|
+
* value cells share ONE width and the trailing items align at one x — the
|
|
27
|
+
* table look. Omit when no row has a trailing (values fill to the edge). */
|
|
28
|
+
trailingWidth?: number;
|
|
29
|
+
/** Min row height. Default 40 (`INLINE_CONTROL_HEIGHT`) so editor, static,
|
|
30
|
+
* and custom rows all sit on the inline-control baseline grid. */
|
|
31
|
+
minHeight?: number;
|
|
32
|
+
/** The usable width the value cell must keep before the table STACKS
|
|
33
|
+
* (default 160). Raise it when a cell holds MORE than one editor (an
|
|
34
|
+
* amount + method pair) — side-by-side columns that technically fit can
|
|
35
|
+
* still crush a multi-editor cell. */
|
|
36
|
+
minValueWidth?: number;
|
|
37
|
+
/** `DetailRow`s (each inherits the table's columns; row props override). */
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
style?: StyleProp<ViewStyle>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The column grid for a stack of `DetailRow`s — label · value · trailing laid
|
|
44
|
+
* out like a TABLE: one label width, one value width, one trailing width, set
|
|
45
|
+
* ONCE on the parent instead of repeated (and drifting) per row. Rows without a
|
|
46
|
+
* trailing still reserve the trailing column, so a `Copy` action on one row
|
|
47
|
+
* never makes its editor narrower than its neighbours'.
|
|
48
|
+
*
|
|
49
|
+
* <DetailTable labelWidth={130} trailingWidth={96}>
|
|
50
|
+
* <DetailRow label="Reference" trailing={<Button title="Copy" … />}>
|
|
51
|
+
* <InlineTextInput … />
|
|
52
|
+
* </DetailRow>
|
|
53
|
+
* <DetailRow label="Priority"><InlineSelect … /></DetailRow>
|
|
54
|
+
* </DetailTable>
|
|
55
|
+
*
|
|
56
|
+
* It owns the 6px row gap (zinc-50 editor chips need visible separation) and
|
|
57
|
+
* the 40px row height of the inline-control grid.
|
|
58
|
+
*
|
|
59
|
+
* RESPONSIVE: the table measures its own container (not the viewport — a table
|
|
60
|
+
* inside a narrow Drawer on a wide screen must adapt too). When the columns
|
|
61
|
+
* would crush the value cell below a usable width, every row STACKS: label
|
|
62
|
+
* above a full-width value row (trailing stays beside the value). No prop —
|
|
63
|
+
* the switch is automatic.
|
|
64
|
+
*/
|
|
65
|
+
export function DetailTable(props: DetailTableProps) {
|
|
66
|
+
const { labelWidth = 130, trailingWidth, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_VALUE_WIDTH, children, style } = props;
|
|
67
|
+
const [width, setWidth] = useState<number | null>(null);
|
|
68
|
+
const stacked =
|
|
69
|
+
width != null && width < labelWidth + (trailingWidth ?? 0) + minValueWidth + 24;
|
|
70
|
+
return (
|
|
71
|
+
<DetailTableContext.Provider value={{ labelWidth, trailingWidth, minHeight, stacked }}>
|
|
72
|
+
<View
|
|
73
|
+
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
|
|
74
|
+
// Measure-then-REVEAL: the unmeasured first frame renders invisible
|
|
75
|
+
// (opacity 0), so the first PAINT the user sees is already in the
|
|
76
|
+
// right mode — no table→stacked reshuffle as a drawer opens.
|
|
77
|
+
style={[stacked ? styles.tableStacked : styles.table, width == null ? styles.unmeasured : null, style]}
|
|
78
|
+
>
|
|
79
|
+
{children}
|
|
80
|
+
</View>
|
|
81
|
+
</DetailTableContext.Provider>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
5
85
|
export interface DetailRowProps {
|
|
6
86
|
/** The field label — rendered `sm muted`. */
|
|
7
87
|
label: ReactNode;
|
|
8
88
|
/** The value — any node (a `Text`, a `Badge`, a stack). */
|
|
9
89
|
children: ReactNode;
|
|
10
90
|
/** An optional right-side slot, rendered as the LAST child of the row (after
|
|
11
|
-
* `children`) — a small action `Button`, a status `Badge`,
|
|
12
|
-
*
|
|
13
|
-
*
|
|
91
|
+
* `children`) — a small action `Button`, a status `Badge`, an `IconButton`.
|
|
92
|
+
* NOT for units: "kg" / "₫" belong IN the value (`InlineNumberInput format`).
|
|
93
|
+
* In form mode (`labelWidth` set) the value column already fills
|
|
94
|
+
* the row, so the trailing pins to the right edge automatically. Inside a
|
|
95
|
+
* `DetailTable` with `trailingWidth` it sits in the reserved trailing column. */
|
|
14
96
|
trailing?: ReactNode;
|
|
15
97
|
/** Fixed label-column width (px) to align labels in a left column — for a peek
|
|
16
98
|
* or form-like stack. Omit for a spread row: the label takes the slack and the
|
|
17
|
-
* value sits at the right edge (the default drawer-detail look).
|
|
99
|
+
* value sits at the right edge (the default drawer-detail look). Inside a
|
|
100
|
+
* `DetailTable` the table's `labelWidth` applies; this overrides per row. */
|
|
18
101
|
labelWidth?: number;
|
|
19
102
|
/** Label text size — `xs` for a compact peek, `sm` (default) for a drawer. */
|
|
20
103
|
labelSize?: "xs" | "sm";
|
|
21
|
-
/** Min row height. Default 28. */
|
|
104
|
+
/** Min row height. Default 28 (or the `DetailTable`'s `minHeight`). */
|
|
22
105
|
minHeight?: number;
|
|
23
106
|
}
|
|
24
107
|
|
|
@@ -27,23 +110,61 @@ export interface DetailRowProps {
|
|
|
27
110
|
* `PressableRow` / `TableRow`). Spread by default (muted label left, value pushed
|
|
28
111
|
* to the right edge); pass `labelWidth` to align labels in a fixed left column —
|
|
29
112
|
* the value column then FILLS the rest of the row, so a stack of inline editors
|
|
30
|
-
* all span the same width (none jumps wider on edit).
|
|
113
|
+
* all span the same width (none jumps wider on edit). For a stack of rows, wrap
|
|
114
|
+
* them in a `DetailTable`: the label/trailing column widths live ONCE on the
|
|
115
|
+
* parent and every row aligns.
|
|
31
116
|
*/
|
|
32
117
|
export function DetailRow(props: DetailRowProps) {
|
|
33
|
-
const
|
|
118
|
+
const table = useContext(DetailTableContext);
|
|
119
|
+
const { label, children, trailing, labelSize = "sm" } = props;
|
|
120
|
+
const labelWidth = props.labelWidth ?? table?.labelWidth;
|
|
121
|
+
const minHeight = props.minHeight ?? table?.minHeight ?? 28;
|
|
34
122
|
const form = labelWidth != null;
|
|
123
|
+
if (table?.stacked) {
|
|
124
|
+
// Stacked mode wears the FORM grammar: the label renders exactly like a
|
|
125
|
+
// `FormField` label (medium, default ink), so a narrow record surface
|
|
126
|
+
// reads as one vocabulary with forms. The COMPONENTS stay separate — a
|
|
127
|
+
// FormField wraps a draft control validated and committed together, an
|
|
128
|
+
// inline editor self-persists — only the look converges.
|
|
129
|
+
return (
|
|
130
|
+
<View style={styles.stackedRow}>
|
|
131
|
+
<Text weight="medium" numberOfLines={1}>
|
|
132
|
+
{label}
|
|
133
|
+
</Text>
|
|
134
|
+
<View style={[styles.stackedValueRow, { minHeight }]}>
|
|
135
|
+
<View style={styles.value}>{children}</View>
|
|
136
|
+
{trailing != null ? trailing : null}
|
|
137
|
+
</View>
|
|
138
|
+
</View>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
35
141
|
return (
|
|
36
142
|
<View style={[styles.row, { minHeight }]}>
|
|
37
143
|
<Text size={labelSize} color="muted" style={form ? { width: labelWidth } : styles.flexLabel}>
|
|
38
144
|
{label}
|
|
39
145
|
</Text>
|
|
40
146
|
{form ? <View style={styles.value}>{children}</View> : children}
|
|
41
|
-
{
|
|
147
|
+
{table?.trailingWidth != null ? (
|
|
148
|
+
<View style={[styles.trailingCell, { width: table.trailingWidth }]}>{trailing}</View>
|
|
149
|
+
) : trailing != null ? (
|
|
150
|
+
trailing
|
|
151
|
+
) : null}
|
|
42
152
|
</View>
|
|
43
153
|
);
|
|
44
154
|
}
|
|
45
155
|
|
|
46
156
|
const styles = StyleSheet.create({
|
|
157
|
+
// 6px between rows — the zinc-50 editor chips need visible separation.
|
|
158
|
+
table: { gap: 6 },
|
|
159
|
+
// Stacked (narrow) mode: label-over-value blocks need more air between rows.
|
|
160
|
+
tableStacked: { gap: 14 },
|
|
161
|
+
unmeasured: { opacity: 0 },
|
|
162
|
+
stackedRow: { gap: 4 },
|
|
163
|
+
stackedValueRow: {
|
|
164
|
+
flexDirection: "row",
|
|
165
|
+
alignItems: "center",
|
|
166
|
+
gap: 12,
|
|
167
|
+
},
|
|
47
168
|
row: {
|
|
48
169
|
flexDirection: "row",
|
|
49
170
|
alignItems: "center",
|
|
@@ -56,4 +177,10 @@ const styles = StyleSheet.create({
|
|
|
56
177
|
// child fills it edge-to-edge. A status Badge or action is NOT a value — pass
|
|
57
178
|
// it as `trailing` (pins right); one placed here would stretch full-width.
|
|
58
179
|
value: { flex: 1 },
|
|
180
|
+
// The reserved trailing column: every row renders it (empty or not) so value
|
|
181
|
+
// cells share one width and trailing items align at one x, like a table.
|
|
182
|
+
trailingCell: {
|
|
183
|
+
flexDirection: "row",
|
|
184
|
+
alignItems: "center",
|
|
185
|
+
},
|
|
59
186
|
});
|
package/src/finding.tsx
CHANGED
|
@@ -1,110 +1,142 @@
|
|
|
1
|
-
import { StyleSheet, View } from "react-native";
|
|
2
|
-
import {
|
|
3
|
-
import { Text } from "./text";
|
|
1
|
+
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
4
3
|
import { Badge } from "./badge";
|
|
5
|
-
import {
|
|
4
|
+
import { Text } from "./text";
|
|
6
5
|
import { Sources, type SourceRef } from "./sources";
|
|
7
6
|
import { useLoticsLocale } from "./locale";
|
|
7
|
+
import { colors, type ColorName } from "./colors";
|
|
8
8
|
|
|
9
9
|
export type FindingSeverity = "critical" | "warning" | "info" | "positive";
|
|
10
10
|
|
|
11
|
+
/** The localized strings — the `finding` LoticsLocale slice. */
|
|
12
|
+
export type FindingLabels = Record<FindingSeverity, string> & {
|
|
13
|
+
/** The delta row's label in `FindingComparison`. */
|
|
14
|
+
difference: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const SEVERITY_COLOR: Record<FindingSeverity, ColorName> = {
|
|
18
|
+
critical: "red",
|
|
19
|
+
warning: "amber",
|
|
20
|
+
info: "zinc",
|
|
21
|
+
positive: "emerald",
|
|
22
|
+
};
|
|
23
|
+
|
|
11
24
|
export interface FindingProps {
|
|
12
|
-
severity
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
/** The headline — what the agent found. */
|
|
25
|
+
/** Ranked severity — the dot badge + its localized word. */
|
|
26
|
+
severity: FindingSeverity;
|
|
27
|
+
/** What the agent found — one line. */
|
|
16
28
|
title: string;
|
|
17
|
-
/**
|
|
29
|
+
/** The explanation under the title. */
|
|
18
30
|
detail?: string;
|
|
19
|
-
/**
|
|
20
|
-
*
|
|
31
|
+
/** The emphasized MEASURE of the finding ("−40 pcs") — prominent in the
|
|
32
|
+
* body (lg, semibold), never a side note. */
|
|
21
33
|
metric?: string;
|
|
22
|
-
/**
|
|
23
|
-
* "vs target"). */
|
|
34
|
+
/** What the metric compares ("invoice vs packing list"). */
|
|
24
35
|
metricCaption?: string;
|
|
25
|
-
/** Provenance — the
|
|
36
|
+
/** Provenance — the one `Sources` idiom, chips at the bottom. */
|
|
26
37
|
sources?: SourceRef[];
|
|
27
|
-
onOpenSource?: (
|
|
28
|
-
/**
|
|
29
|
-
action
|
|
38
|
+
onOpenSource?: (source: SourceRef) => void;
|
|
39
|
+
/** Compose anything extra between the body and the sources — a band pair,
|
|
40
|
+
* an action row, a custom block. */
|
|
41
|
+
children?: ReactNode;
|
|
42
|
+
style?: StyleProp<ViewStyle>;
|
|
30
43
|
}
|
|
31
44
|
|
|
32
|
-
// Severity reads through a coloured dot badge + the order it's stacked in (most
|
|
33
|
-
// severe first): red critical, amber warning, blue note, emerald on-track. The
|
|
34
|
-
// metric takes the same colour. Words resolve prop → locale → English default.
|
|
35
|
-
export type FindingLabels = Record<FindingSeverity, string>;
|
|
36
|
-
const SEV_COLOR: Record<FindingSeverity, ColorName> = {
|
|
37
|
-
critical: "red",
|
|
38
|
-
warning: "amber",
|
|
39
|
-
info: "blue",
|
|
40
|
-
positive: "emerald",
|
|
41
|
-
};
|
|
42
|
-
|
|
43
45
|
/**
|
|
44
|
-
* One ranked insight from an AI
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* One ranked insight from an AI check — a cross-check discrepancy, an audit
|
|
47
|
+
* observation, a briefing item. Good defaults (severity word · title · detail
|
|
48
|
+
* · the PROMINENT metric · `Sources` chips) with a `children` slot for any
|
|
49
|
+
* extra composition. Display-only: a finding informs the verdict the host
|
|
50
|
+
* records; it decides nothing itself. Stack several most-severe first —
|
|
51
|
+
* inside a `ChangeReview` wrap each in a display-only `Change` (the family's
|
|
52
|
+
* dividers apply); standalone, separate them yourself.
|
|
50
53
|
*/
|
|
51
54
|
export function Finding(props: FindingProps) {
|
|
52
|
-
const
|
|
53
|
-
const words = { ...useLoticsLocale().finding, ...props.labels };
|
|
54
|
-
const sev = { word: words[severity], color: SEV_COLOR[severity] };
|
|
55
|
+
const words = useLoticsLocale().finding;
|
|
55
56
|
return (
|
|
56
|
-
<View style={styles.
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
</Text>
|
|
65
|
-
{props.metricCaption ? (
|
|
66
|
-
<Text size="xs" color="muted">
|
|
67
|
-
{props.metricCaption}
|
|
68
|
-
</Text>
|
|
69
|
-
) : null}
|
|
70
|
-
</View>
|
|
71
|
-
) : null}
|
|
72
|
-
</View>
|
|
73
|
-
|
|
74
|
-
<View style={styles.body}>
|
|
75
|
-
<Text size="md" weight="semibold" numberOfLines={2}>
|
|
76
|
-
{props.title}
|
|
57
|
+
<View style={[styles.root, props.style]}>
|
|
58
|
+
<Badge variant="dot" color={SEVERITY_COLOR[props.severity]} label={words[props.severity]} />
|
|
59
|
+
<Text size="sm" weight="medium">
|
|
60
|
+
{props.title}
|
|
61
|
+
</Text>
|
|
62
|
+
{props.detail ? (
|
|
63
|
+
<Text size="sm" color="muted">
|
|
64
|
+
{props.detail}
|
|
77
65
|
</Text>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
66
|
+
) : null}
|
|
67
|
+
{props.metric ? (
|
|
68
|
+
<View style={styles.metricBlock}>
|
|
69
|
+
<Text size="lg" weight="semibold" tabular>
|
|
70
|
+
{props.metric}
|
|
81
71
|
</Text>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
72
|
+
{props.metricCaption ? (
|
|
73
|
+
<Text size="xs" color="muted">
|
|
74
|
+
{props.metricCaption}
|
|
75
|
+
</Text>
|
|
76
|
+
) : null}
|
|
77
|
+
</View>
|
|
78
|
+
) : null}
|
|
79
|
+
{props.children}
|
|
85
80
|
{props.sources && props.sources.length > 0 ? (
|
|
86
|
-
<Sources sources={props.sources} onOpen={props.onOpenSource} />
|
|
81
|
+
<Sources label={null} sources={props.sources} onOpen={props.onOpenSource} />
|
|
87
82
|
) : null}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
83
|
+
</View>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface FindingComparisonProps {
|
|
88
|
+
/** The disagreeing sides — each value with where it came from
|
|
89
|
+
* ("invoice.pdf" · "480 pcs"). Two or more. */
|
|
90
|
+
values: { label: string; value: string }[];
|
|
91
|
+
/** The computed difference, EMPHASIZED ("−40 pcs"). */
|
|
92
|
+
delta?: string;
|
|
93
|
+
/** Label for the delta row. Defaults to the localized "Difference". */
|
|
94
|
+
deltaLabel?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The expected-vs-actual body of a `Finding` — each side a labeled row
|
|
99
|
+
* (source · value), a hairline, then the DELTA emphasized. Compose it as the
|
|
100
|
+
* finding's children; it replaces ad-hoc metric text for any
|
|
101
|
+
* one-value-disagrees insight (quantities, totals, dates).
|
|
102
|
+
*/
|
|
103
|
+
export function FindingComparison(props: FindingComparisonProps) {
|
|
104
|
+
const words = useLoticsLocale().finding;
|
|
105
|
+
return (
|
|
106
|
+
<View style={styles.comparison}>
|
|
107
|
+
{props.values.map((v, i) => (
|
|
108
|
+
<View key={`${v.label}-${i}`} style={styles.comparisonRow}>
|
|
109
|
+
<Text size="sm" color="muted" style={styles.comparisonLabel} numberOfLines={1}>
|
|
110
|
+
{v.label}
|
|
111
|
+
</Text>
|
|
112
|
+
<Text size="sm" weight="semibold" tabular numberOfLines={1} style={{ flexShrink: 1 }}>
|
|
113
|
+
{v.value}
|
|
114
|
+
</Text>
|
|
91
115
|
</View>
|
|
116
|
+
))}
|
|
117
|
+
{props.delta ? (
|
|
118
|
+
<>
|
|
119
|
+
<View style={styles.comparisonRule} />
|
|
120
|
+
<View style={styles.comparisonRow}>
|
|
121
|
+
<Text size="sm" color="muted" style={styles.comparisonLabel} numberOfLines={1}>
|
|
122
|
+
{props.deltaLabel ?? words.difference}
|
|
123
|
+
</Text>
|
|
124
|
+
<Text size="lg" weight="semibold" tabular>
|
|
125
|
+
{props.delta}
|
|
126
|
+
</Text>
|
|
127
|
+
</View>
|
|
128
|
+
</>
|
|
92
129
|
) : null}
|
|
93
130
|
</View>
|
|
94
131
|
);
|
|
95
132
|
}
|
|
96
133
|
|
|
97
134
|
const styles = StyleSheet.create({
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
gap: 12,
|
|
105
|
-
},
|
|
106
|
-
header: { flexDirection: "row", alignItems: "center", gap: 12 },
|
|
107
|
-
metric: { flexDirection: "row", alignItems: "baseline", gap: 5 },
|
|
108
|
-
body: { gap: 6 },
|
|
109
|
-
actionRow: { flexDirection: "row", justifyContent: "flex-end" },
|
|
135
|
+
root: { gap: 6, alignItems: "flex-start" },
|
|
136
|
+
metricBlock: { gap: 1, paddingVertical: 2 },
|
|
137
|
+
comparison: { gap: 6, paddingVertical: 4, alignSelf: "stretch", maxWidth: 420 },
|
|
138
|
+
comparisonRow: { flexDirection: "row", alignItems: "baseline", gap: 12 },
|
|
139
|
+
comparisonLabel: { width: 130 },
|
|
140
|
+
comparisonRule: { height: 1, backgroundColor: colors.zinc[100] },
|
|
110
141
|
});
|
|
142
|
+
|
|
@@ -31,7 +31,7 @@ export function FloatingActionBar(props: FloatingActionBarProps) {
|
|
|
31
31
|
<View pointerEvents="box-none" style={styles.wrap}>
|
|
32
32
|
<Card style={styles.bar}>
|
|
33
33
|
<Text size="sm" weight="semibold" tabular>{`${count} ${label}`}</Text>
|
|
34
|
-
<Button title={clearLabel} color="
|
|
34
|
+
<Button title={clearLabel} color="muted" onPress={onClear} />
|
|
35
35
|
{children}
|
|
36
36
|
</Card>
|
|
37
37
|
</View>
|