@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/AGENTS.md +128 -92
- package/examples/tpl_dieline.tsx +3 -3
- package/examples/tpl_documents.tsx +790 -0
- package/examples/tpl_lookup.tsx +37 -10
- package/examples/tpl_task_board.tsx +32 -19
- package/examples/tpl_tasks.tsx +32 -19
- package/package.json +2 -9
- package/src/agent_run.tsx +1 -1
- package/src/change_review.tsx +732 -236
- package/src/composer.tsx +22 -1
- package/src/confidence.tsx +1 -1
- package/src/control_surface.ts +0 -14
- package/src/finding.tsx +112 -80
- package/src/floating_action_bar.tsx +1 -1
- package/src/locale.tsx +22 -16
- package/src/sources.tsx +8 -5
- package/src/use_option_list.test.ts +39 -0
- package/src/use_option_list.ts +5 -2
- package/examples/tpl_assistant.tsx +0 -174
- package/examples/tpl_briefing.tsx +0 -121
- package/examples/tpl_compare.tsx +0 -133
- package/examples/tpl_crosscheck.tsx +0 -120
- package/examples/tpl_draft.tsx +0 -163
- package/examples/tpl_extract.tsx +0 -193
- package/examples/tpl_match.tsx +0 -134
- package/examples/tpl_ratedesk.tsx +0 -386
- package/examples/tpl_triage.tsx +0 -112
- 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
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/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>
|
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
|
-
|
|
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: "
|
|
107
|
+
acceptAll: "Keep all",
|
|
108
108
|
discard: "Discard",
|
|
109
109
|
apply: "Apply",
|
|
110
|
-
|
|
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
|
-
|
|
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: "
|
|
179
|
+
discard: "Bỏ hết",
|
|
178
180
|
apply: "Áp dụng",
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
});
|
package/src/use_option_list.ts
CHANGED
|
@@ -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"
|
|
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]);
|