@lotics/ui 7.19.2 → 8.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/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,
@@ -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 `ReviewCard` or `ChangeReview`.
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");
@@ -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/finding.tsx CHANGED
@@ -1,110 +1,142 @@
1
- import { StyleSheet, View } from "react-native";
2
- import { colors, solid, type ColorName } from "./colors";
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 { Button } from "./button";
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?: FindingSeverity;
13
- /** Translated severity words. Defaults resolve through the locale ("Critical" …). */
14
- labels?: Partial<FindingLabels>;
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
- /** One or two lines explaining it. */
29
+ /** The explanation under the title. */
18
30
  detail?: string;
19
- /** A headline figure tied to the finding (a value, a delta, a count), shown in
20
- * the header row in the severity colour. */
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
- /** A short caption beside the metric giving it context ("exposure", "overdue",
23
- * "vs target"). */
34
+ /** What the metric compares ("invoice vs packing list"). */
24
35
  metricCaption?: string;
25
- /** Provenance — the records / documents the finding rests on. */
36
+ /** Provenance — the one `Sources` idiom, chips at the bottom. */
26
37
  sources?: SourceRef[];
27
- onOpenSource?: (s: SourceRef) => void;
28
- /** The single action the finding suggests. */
29
- action?: { label: string; onPress: () => void };
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 briefing / audit / anomaly scan — a severity dot
45
- * badge and a headline figure share the top row, the headline + explanation read
46
- * below, then provenance and the single action it suggests. Severity reads
47
- * through the coloured badge + metric and the stacking order (most severe first).
48
- * Unlike `Callout` (a flat inline status), a Finding is ranked, sourced, and
49
- * carries its own next action.
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 severity = props.severity ?? "info";
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.card}>
57
- <View style={styles.header}>
58
- <Badge variant="dot" color={sev.color} label={sev.word} />
59
- <View style={{ flex: 1 }} />
60
- {props.metric ? (
61
- <View style={styles.metric}>
62
- <Text size="lg" weight="semibold" tabular style={{ color: solid(sev.color), letterSpacing: -0.3 }}>
63
- {props.metric}
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
- {props.detail ? (
79
- <Text size="sm" color="muted">
80
- {props.detail}
66
+ ) : null}
67
+ {props.metric ? (
68
+ <View style={styles.metricBlock}>
69
+ <Text size="lg" weight="semibold" tabular>
70
+ {props.metric}
81
71
  </Text>
82
- ) : null}
83
- </View>
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
- {props.action ? (
89
- <View style={styles.actionRow}>
90
- <Button title={props.action.label} color="secondary" onPress={props.action.onPress} />
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
- card: {
99
- borderWidth: 1,
100
- borderColor: colors.border,
101
- backgroundColor: colors.white,
102
- borderRadius: 12,
103
- padding: 16,
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="secondary" onPress={onClear} />
34
+ <Button title={clearLabel} color="muted" onPress={onClear} />
35
35
  {children}
36
36
  </Card>
37
37
  </View>
package/src/locale.tsx CHANGED
@@ -2,10 +2,10 @@ import { createContext, useContext, type ReactNode } from "react";
2
2
  import { type PaginationLabels } from "./pagination";
3
3
  import { type SortHeaderLabels } from "./sort_header";
4
4
  import { type ConfidenceLabels } from "./confidence";
5
- import { type FindingLabels } from "./finding";
6
5
  import { type RemainderMeterLabels } from "./remainder_meter";
7
6
  import { type DateRangeFilterFieldLabels } from "./date_range_filter_field";
8
7
  import { type GalleryLabels } from "./file_preview_types";
8
+ import { type FindingLabels } from "./finding";
9
9
 
10
10
  /**
11
11
  * The kit's localizable strings, one slice per string-bearing component. A
@@ -39,24 +39,26 @@ export interface LoticsLocale {
39
39
  /** `Confidence`: the full level phrase ("High confidence" …). */
40
40
  confidence: ConfidenceLabels;
41
41
  /** `Finding`: the severity badge word ("Critical" …). */
42
- finding: FindingLabels;
43
42
  /** `RemainderMeter`: the applied / remaining / over / exact captions. */
44
43
  remainderMeter: Required<RemainderMeterLabels>;
45
44
  /** `ChangeReview` (+ `ChangeReviewActions`): the review-card chrome — the
46
45
  * Keep/Drop verdicts, Undo, the Applied/Discarded tags, the kept-counter,
47
46
  * the title, and the commit-bar Accept-all / Discard. */
47
+ finding: FindingLabels;
48
48
  changeReview: {
49
49
  title: string;
50
50
  accept: string;
51
51
  reject: string;
52
52
  undo: string;
53
- applied: string;
54
- discarded: string;
55
53
  keptCount: (kept: number, total: number) => string;
56
54
  acceptAll: string;
57
55
  discard: string;
58
56
  apply: string;
59
- applyKept: string;
57
+ recommended: string;
58
+ customValue: string;
59
+ opAdd: string;
60
+ opEdit: string;
61
+ opRemove: string;
60
62
  };
61
63
  /** `DateRangeFilterField` (and the `DateFilter` panel it wraps): presets,
62
64
  * from/to, the footer Clear/Done, the trigger placeholder, and the time-field
@@ -89,25 +91,27 @@ export const en: LoticsLocale = {
89
91
  formField: { optional: "Optional" },
90
92
  drawer: { previous: "Previous record", next: "Next record", close: "Close" },
91
93
  confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
92
- finding: { critical: "Critical", warning: "Warning", info: "Note", positive: "On track" },
93
94
  remainderMeter: {
94
95
  applied: (allocated, total) => `${allocated} of ${total} applied`,
95
96
  remaining: (remainder) => `${remainder} unapplied`,
96
97
  over: (amount) => `Over by ${amount}`,
97
98
  exact: "Fully applied",
98
99
  },
100
+ finding: { critical: "Critical", warning: "Warning", info: "Note", positive: "On track", difference: "Difference" },
99
101
  changeReview: {
100
102
  title: "Suggested edits",
101
103
  accept: "Keep",
102
104
  reject: "Drop",
103
105
  undo: "Undo",
104
- applied: "Applied",
105
- discarded: "Discarded",
106
106
  keptCount: (kept, total) => `${kept} of ${total} kept`,
107
- acceptAll: "Accept all",
107
+ acceptAll: "Keep all",
108
108
  discard: "Discard",
109
109
  apply: "Apply",
110
- applyKept: "Apply kept",
110
+ recommended: "AI pick",
111
+ customValue: "Type another value",
112
+ opAdd: "Add",
113
+ opEdit: "Edit",
114
+ opRemove: "Delete",
111
115
  },
112
116
  dateRange: {
113
117
  year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM",
@@ -158,25 +162,27 @@ export const vi: LoticsLocale = {
158
162
  formField: { optional: "Tùy chọn" },
159
163
  drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
160
164
  confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
161
- finding: { critical: "Nghiêm trọng", warning: "Cảnh báo", info: "Ghi chú", positive: "Đúng tiến độ" },
162
165
  remainderMeter: {
163
166
  applied: (allocated, total) => `Đã phân bổ ${allocated}/${total}`,
164
167
  remaining: (remainder) => `Còn ${remainder}`,
165
168
  over: (amount) => `Vượt ${amount}`,
166
169
  exact: "Đã phân bổ đủ",
167
170
  },
171
+ finding: { critical: "Nghiêm trọng", warning: "Cảnh báo", info: "Ghi chú", positive: "Đúng tiến độ", difference: "Chênh lệch" },
168
172
  changeReview: {
169
173
  title: "Đề xuất chỉnh sửa",
170
174
  accept: "Giữ",
171
175
  reject: "Bỏ",
172
176
  undo: "Hoàn tác",
173
- applied: "Đã áp dụng",
174
- discarded: "Đã bỏ",
175
- keptCount: (kept, total) => `Giữ ${kept}/${total}`,
177
+ keptCount: (kept, total) => `Đã giữ ${kept}/${total}`,
176
178
  acceptAll: "Giữ tất cả",
177
- discard: "Hủy",
179
+ discard: "Bỏ hết",
178
180
  apply: "Áp dụng",
179
- applyKept: "Áp dụng mục đã giữ",
181
+ recommended: "AI đề xuất",
182
+ customValue: "Nhập giá trị khác",
183
+ opAdd: "Thêm",
184
+ opEdit: "Sửa",
185
+ opRemove: "Xóa",
180
186
  },
181
187
  dateRange: {
182
188
  year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH",
package/src/sources.tsx CHANGED
@@ -16,8 +16,9 @@ export interface SourceRef {
16
16
 
17
17
  export interface SourcesProps {
18
18
  sources: SourceRef[];
19
- /** Eyebrow above the chips. Default "Sources". */
20
- label?: string;
19
+ /** Eyebrow above the chips. Default "Sources"; pass `null` for chips only —
20
+ * the form used INSIDE a `Change` (a review card's own header/body). */
21
+ label?: string | null;
21
22
  /** Pass to make each chip openable (jump to the record / document / page). The
22
23
  * chips then show a hover state + an open glyph; the host does the navigation. */
23
24
  onOpen?: (source: SourceRef) => void;
@@ -45,9 +46,11 @@ export function Sources(props: SourcesProps) {
45
46
  if (props.sources.length === 0) return null;
46
47
  return (
47
48
  <View style={{ gap: 8 }}>
48
- <Text size="xs" color="muted" weight="medium">
49
- {props.label ?? "Sources"}
50
- </Text>
49
+ {props.label === null ? null : (
50
+ <Text size="xs" color="muted" weight="medium">
51
+ {props.label ?? "Sources"}
52
+ </Text>
53
+ )}
51
54
  <View style={styles.row}>
52
55
  {props.sources.map((s) =>
53
56
  props.onOpen ? (
@@ -190,4 +190,43 @@ describe("useOptionList", () => {
190
190
  });
191
191
  expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["z"]);
192
192
  });
193
+
194
+ it("controlled server-filtered mode browses recents on an empty query", () => {
195
+ const recentOptions = [
196
+ { value: "r1", label: "Recent one" },
197
+ { value: "r2", label: "Recent two" },
198
+ ];
199
+ // A server search has nothing for an empty term — recents are the idle list.
200
+ const params: UseOptionListParams<string, false> = {
201
+ options: [],
202
+ search: { mode: "controlled", query: "", serverFiltered: true },
203
+ recentOptions,
204
+ };
205
+ const view = renderHook((p: UseOptionListParams<string, false>) => useOptionList(p), {
206
+ initialProps: params,
207
+ });
208
+ expect(view.result.current.searching).toBe(false);
209
+ expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["r1", "r2"]);
210
+ // Typing hands the list back to the server's results.
211
+ view.rerender({
212
+ ...params,
213
+ options: [{ value: "z", label: "Zucchini" }],
214
+ search: { mode: "controlled", query: "zu", serverFiltered: true },
215
+ });
216
+ expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["z"]);
217
+ });
218
+
219
+ it("an empty controlled query without recents browses the full option set", () => {
220
+ // The `recentOptions ?? options` idle contract — a consumer that supplies
221
+ // browse options (server or local) and no recents keeps its full list.
222
+ for (const serverFiltered of [true, false]) {
223
+ const view = renderHook((p: UseOptionListParams<string, false>) => useOptionList(p), {
224
+ initialProps: {
225
+ options: OPTS,
226
+ search: { mode: "controlled" as const, query: "", serverFiltered },
227
+ },
228
+ });
229
+ expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["a", "b", "c"]);
230
+ }
231
+ });
193
232
  });
@@ -172,12 +172,15 @@ export function useOptionList<T extends string, MULTI extends boolean = false, D
172
172
  [options],
173
173
  );
174
174
  const filtered = useMemo(() => {
175
- if (searchMode === "none" || serverFiltered) return cleanOptions;
175
+ if (searchMode === "none") return cleanOptions;
176
176
  if (!searching) {
177
177
  // Empty query: a controlled (Combobox) field browses recents, else its full
178
- // local set; an internal field shows everything.
178
+ // local set; an internal field shows everything. This must precede the
179
+ // server-filtered short-circuit — a server search returns nothing for an
180
+ // empty term, so recents are the only idle content it has.
179
181
  return searchMode === "controlled" ? (recentOptions ?? cleanOptions) : cleanOptions;
180
182
  }
183
+ if (serverFiltered) return cleanOptions;
181
184
  const q = normalizeForSearch(query);
182
185
  return cleanOptions.filter((o) => normalizeForSearch(o.label ?? o.value).includes(q));
183
186
  }, [searchMode, serverFiltered, searching, cleanOptions, recentOptions, query]);