@lotics/ui 4.1.0 → 4.3.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 +97 -6
- package/examples/tpl_assistant.tsx +141 -0
- package/examples/tpl_dieline.tsx +252 -0
- package/examples/tpl_draft.tsx +165 -0
- package/examples/tpl_extract.tsx +186 -0
- package/package.json +14 -1
- package/src/agent_progress.tsx +71 -0
- package/src/agent_run.tsx +194 -0
- package/src/assisted_field.tsx +93 -0
- package/src/change_review.tsx +166 -0
- package/src/chip_group.tsx +16 -13
- package/src/clarify.tsx +68 -0
- package/src/colors.test.ts +45 -0
- package/src/colors.ts +25 -0
- package/src/confidence.tsx +31 -0
- package/src/file_dropzone.tsx +5 -3
- package/src/inline_edit.tsx +12 -5
- package/src/inline_member_select.tsx +62 -0
- package/src/inline_select.tsx +6 -2
- package/src/member_chip.tsx +51 -0
- package/src/member_select.tsx +81 -0
- package/src/option_badge.tsx +58 -0
- package/src/prompt_field.tsx +150 -0
- package/src/sources.tsx +92 -0
- package/src/suggestion.tsx +102 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { solid, tint } from "./colors";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { IconButton } from "./icon_button";
|
|
6
|
+
import { LinkButton } from "./link_button";
|
|
7
|
+
import { Confidence, type ConfidenceLevel } from "./confidence";
|
|
8
|
+
|
|
9
|
+
export interface AssistedFieldProps {
|
|
10
|
+
label: string;
|
|
11
|
+
/** The current value — an AI estimate until confirmed, or the final value. */
|
|
12
|
+
value: string;
|
|
13
|
+
/** Unit suffix shown after the value (mm, cm, kg). */
|
|
14
|
+
unit?: string;
|
|
15
|
+
confidence?: ConfidenceLevel;
|
|
16
|
+
confidenceScore?: number;
|
|
17
|
+
/** True while the value is still an unconfirmed AI estimate — it reads
|
|
18
|
+
* amber-tinted with a Confidence chip and a Confirm action (visible
|
|
19
|
+
* provenance). Confirm or override flips it to a plain confirmed value. */
|
|
20
|
+
estimated?: boolean;
|
|
21
|
+
onConfirm?: () => void;
|
|
22
|
+
/** Override — the host swaps in its own inline input to edit the value. */
|
|
23
|
+
onEdit?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A form value the AI pre-filled — shown as a tinted ESTIMATE the human
|
|
28
|
+
* confirms (→ plain, checked) or overrides, never silently committed. The
|
|
29
|
+
* provenance is always visible: an estimate looks different from a value a
|
|
30
|
+
* person stood behind. Pair with `Confidence`; for a whole extracted record,
|
|
31
|
+
* stack several and add one "Confirm all" at the form level.
|
|
32
|
+
*/
|
|
33
|
+
export function AssistedField(props: AssistedFieldProps) {
|
|
34
|
+
const { label, value, unit, estimated, confidence, confidenceScore, onConfirm, onEdit } = props;
|
|
35
|
+
const display = unit ? `${value} ${unit}` : value;
|
|
36
|
+
return (
|
|
37
|
+
<View style={styles.row}>
|
|
38
|
+
<Text size="sm" color="muted" style={styles.label}>
|
|
39
|
+
{label}
|
|
40
|
+
</Text>
|
|
41
|
+
<View style={styles.valueCol}>
|
|
42
|
+
<View style={styles.valueRow}>
|
|
43
|
+
{estimated ? (
|
|
44
|
+
<View style={styles.estimatePill}>
|
|
45
|
+
<Icon name="sparkles" size={11} color={solid("violet")} />
|
|
46
|
+
<Text size="sm" weight="medium" tabular>
|
|
47
|
+
{display}
|
|
48
|
+
</Text>
|
|
49
|
+
</View>
|
|
50
|
+
) : (
|
|
51
|
+
<View style={styles.confirmedRow}>
|
|
52
|
+
<Text size="sm" weight="medium" tabular>
|
|
53
|
+
{display}
|
|
54
|
+
</Text>
|
|
55
|
+
<Icon name="circle-check" size={13} color={solid("emerald")} />
|
|
56
|
+
</View>
|
|
57
|
+
)}
|
|
58
|
+
{onEdit ? (
|
|
59
|
+
<IconButton icon="square-pen" size="sm" color="none" accessibilityLabel={`Edit ${label}`} onPress={onEdit} />
|
|
60
|
+
) : null}
|
|
61
|
+
</View>
|
|
62
|
+
{estimated ? (
|
|
63
|
+
<View style={styles.estimateMeta}>
|
|
64
|
+
{confidence != null || confidenceScore != null ? (
|
|
65
|
+
<Confidence level={confidence} score={confidenceScore} />
|
|
66
|
+
) : null}
|
|
67
|
+
{onConfirm ? <LinkButton title="Confirm" onPress={onConfirm} /> : null}
|
|
68
|
+
</View>
|
|
69
|
+
) : null}
|
|
70
|
+
</View>
|
|
71
|
+
</View>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const styles = StyleSheet.create({
|
|
76
|
+
row: { flexDirection: "row", alignItems: "flex-start", gap: 16, paddingVertical: 8 },
|
|
77
|
+
label: { width: 132, paddingTop: 6 },
|
|
78
|
+
valueCol: { flex: 1, gap: 6, alignItems: "flex-end" },
|
|
79
|
+
valueRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
|
80
|
+
estimatePill: {
|
|
81
|
+
flexDirection: "row",
|
|
82
|
+
alignItems: "center",
|
|
83
|
+
gap: 6,
|
|
84
|
+
paddingHorizontal: 10,
|
|
85
|
+
paddingVertical: 5,
|
|
86
|
+
borderRadius: 8,
|
|
87
|
+
backgroundColor: tint("amber", 0.12),
|
|
88
|
+
borderWidth: 1,
|
|
89
|
+
borderColor: tint("amber", 0.35),
|
|
90
|
+
},
|
|
91
|
+
confirmedRow: { flexDirection: "row", alignItems: "center", gap: 6, paddingVertical: 5 },
|
|
92
|
+
estimateMeta: { flexDirection: "row", alignItems: "center", gap: 10 },
|
|
93
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { colors, solid, tint } from "./colors";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
import { IconButton } from "./icon_button";
|
|
7
|
+
import { LinkButton } from "./link_button";
|
|
8
|
+
|
|
9
|
+
export type ChangeReviewItemStatus = "pending" | "accepted" | "rejected";
|
|
10
|
+
|
|
11
|
+
export interface ChangeReviewItem {
|
|
12
|
+
/** What changed — a field/parameter name. */
|
|
13
|
+
label: string;
|
|
14
|
+
/** Prior value. Omit for a newly-added value (renders as just the new value). */
|
|
15
|
+
before?: string;
|
|
16
|
+
/** Proposed value. */
|
|
17
|
+
after: string;
|
|
18
|
+
/** Per-item decision — only meaningful in the 1-by-1 mode (when the host
|
|
19
|
+
* passes `onAcceptItem`/`onRejectItem`). */
|
|
20
|
+
status?: ChangeReviewItemStatus;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ChangeReviewProps {
|
|
24
|
+
changes: ChangeReviewItem[];
|
|
25
|
+
/** The instruction that produced the edit ("make it 5 mm taller"). */
|
|
26
|
+
summary?: string;
|
|
27
|
+
onApply?: () => void;
|
|
28
|
+
onDiscard?: () => void;
|
|
29
|
+
applyLabel?: string;
|
|
30
|
+
/** Once resolved the card settles to a quiet outcome line (no actions). */
|
|
31
|
+
status?: "open" | "applied" | "discarded";
|
|
32
|
+
/** Providing either flips the card into 1-BY-1 mode: each change row gets its
|
|
33
|
+
* own ✓/✕ and reflects `item.status`; the footer counts the accepted set and
|
|
34
|
+
* Apply commits only those. The "review the agent's suggested edits one by
|
|
35
|
+
* one" pattern. Omit both for whole-set apply/discard. */
|
|
36
|
+
onAcceptItem?: (index: number) => void;
|
|
37
|
+
onRejectItem?: (index: number) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* An agent-proposed edit to existing state, shown as a before→after diff the
|
|
42
|
+
* human reviews before it lands — never auto-applied. Two modes: whole-set
|
|
43
|
+
* (apply / discard the lot) or, when the host passes `onAcceptItem`/
|
|
44
|
+
* `onRejectItem`, 1-BY-1 — each suggested change accepted or rejected on its
|
|
45
|
+
* own, the footer applying only the accepted set. Where `Suggestion` proposes
|
|
46
|
+
* a NEW value, this reviews CHANGES to existing ones.
|
|
47
|
+
*/
|
|
48
|
+
export function ChangeReview(props: ChangeReviewProps) {
|
|
49
|
+
const status = props.status ?? "open";
|
|
50
|
+
const perItem = props.onAcceptItem != null || props.onRejectItem != null;
|
|
51
|
+
const accepted = props.changes.filter((c) => c.status === "accepted").length;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<View style={[styles.card, status !== "open" ? styles.resolved : null]}>
|
|
55
|
+
{props.summary ? (
|
|
56
|
+
<View style={styles.summary}>
|
|
57
|
+
<Icon name="sparkles" size={13} color={solid("violet")} />
|
|
58
|
+
<Text size="sm" weight="medium" style={{ flex: 1 }}>
|
|
59
|
+
{props.summary}
|
|
60
|
+
</Text>
|
|
61
|
+
</View>
|
|
62
|
+
) : null}
|
|
63
|
+
|
|
64
|
+
<View style={styles.changes}>
|
|
65
|
+
{props.changes.map((c, i) => {
|
|
66
|
+
const rejected = c.status === "rejected";
|
|
67
|
+
const ok = c.status === "accepted";
|
|
68
|
+
return (
|
|
69
|
+
<View key={`${c.label}-${i}`} style={styles.change}>
|
|
70
|
+
<Text size="xs" color="muted" transform="uppercase" style={styles.changeLabel}>
|
|
71
|
+
{c.label}
|
|
72
|
+
</Text>
|
|
73
|
+
<View style={styles.diff}>
|
|
74
|
+
{c.before != null ? (
|
|
75
|
+
<>
|
|
76
|
+
<Text size="sm" color="muted" tabular style={styles.before}>
|
|
77
|
+
{c.before}
|
|
78
|
+
</Text>
|
|
79
|
+
<Icon name="arrow-right" size={13} color={colors.zinc[400]} />
|
|
80
|
+
</>
|
|
81
|
+
) : null}
|
|
82
|
+
<Text size="sm" weight="medium" tabular color={rejected ? "muted" : "default"} style={rejected ? styles.before : undefined}>
|
|
83
|
+
{c.after}
|
|
84
|
+
</Text>
|
|
85
|
+
</View>
|
|
86
|
+
{perItem && status === "open" ? (
|
|
87
|
+
<View style={styles.itemControls}>
|
|
88
|
+
<IconButton
|
|
89
|
+
icon="check"
|
|
90
|
+
size="sm"
|
|
91
|
+
color="none"
|
|
92
|
+
iconColor={ok ? solid("emerald") : colors.zinc[400]}
|
|
93
|
+
accessibilityLabel={`Accept — ${c.label}`}
|
|
94
|
+
onPress={() => props.onAcceptItem?.(i)}
|
|
95
|
+
/>
|
|
96
|
+
<IconButton
|
|
97
|
+
icon="x"
|
|
98
|
+
size="sm"
|
|
99
|
+
color="none"
|
|
100
|
+
iconColor={rejected ? solid("red") : colors.zinc[400]}
|
|
101
|
+
accessibilityLabel={`Reject — ${c.label}`}
|
|
102
|
+
onPress={() => props.onRejectItem?.(i)}
|
|
103
|
+
/>
|
|
104
|
+
</View>
|
|
105
|
+
) : null}
|
|
106
|
+
</View>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
</View>
|
|
110
|
+
|
|
111
|
+
{status !== "open" ? (
|
|
112
|
+
<View style={styles.outcome}>
|
|
113
|
+
<Icon
|
|
114
|
+
name={status === "applied" ? "circle-check" : "rotate-ccw"}
|
|
115
|
+
size={14}
|
|
116
|
+
color={status === "applied" ? solid("emerald") : colors.zinc[400]}
|
|
117
|
+
/>
|
|
118
|
+
<Text size="xs" color="muted">
|
|
119
|
+
{status === "applied" ? "Applied" : "Discarded"}
|
|
120
|
+
</Text>
|
|
121
|
+
</View>
|
|
122
|
+
) : (
|
|
123
|
+
<View style={styles.footer}>
|
|
124
|
+
<View style={{ flex: 1 }}>
|
|
125
|
+
{perItem ? (
|
|
126
|
+
<Text size="xs" color="muted">
|
|
127
|
+
{accepted} of {props.changes.length} accepted
|
|
128
|
+
</Text>
|
|
129
|
+
) : null}
|
|
130
|
+
</View>
|
|
131
|
+
{props.onDiscard ? <LinkButton title={perItem ? "Reject all" : "Discard"} onPress={props.onDiscard} /> : null}
|
|
132
|
+
{props.onApply ? (
|
|
133
|
+
<Button
|
|
134
|
+
title={props.applyLabel ?? (perItem ? "Apply accepted" : "Apply")}
|
|
135
|
+
color="primary"
|
|
136
|
+
shape="rounded"
|
|
137
|
+
disabled={perItem && accepted === 0}
|
|
138
|
+
onPress={props.onApply}
|
|
139
|
+
/>
|
|
140
|
+
) : null}
|
|
141
|
+
</View>
|
|
142
|
+
)}
|
|
143
|
+
</View>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const styles = StyleSheet.create({
|
|
148
|
+
card: {
|
|
149
|
+
borderWidth: 1,
|
|
150
|
+
borderColor: tint("violet", 0.35),
|
|
151
|
+
backgroundColor: colors.white,
|
|
152
|
+
borderRadius: 12,
|
|
153
|
+
padding: 14,
|
|
154
|
+
gap: 12,
|
|
155
|
+
},
|
|
156
|
+
resolved: { borderColor: colors.border, backgroundColor: colors.zinc[50] },
|
|
157
|
+
summary: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
158
|
+
changes: { gap: 8 },
|
|
159
|
+
change: { flexDirection: "row", alignItems: "center", gap: 12 },
|
|
160
|
+
changeLabel: { width: 120 },
|
|
161
|
+
diff: { flexDirection: "row", alignItems: "center", gap: 8, flex: 1 },
|
|
162
|
+
before: { textDecorationLine: "line-through" },
|
|
163
|
+
itemControls: { flexDirection: "row", alignItems: "center", gap: 2 },
|
|
164
|
+
outcome: { flexDirection: "row", alignItems: "center", gap: 6 },
|
|
165
|
+
footer: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
166
|
+
});
|
package/src/chip_group.tsx
CHANGED
|
@@ -3,17 +3,19 @@ import { Text } from "./text";
|
|
|
3
3
|
import { PressableHighlight } from "./pressable_highlight";
|
|
4
4
|
import { pillSurfaceStyle } from "./control_surface";
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
6
|
+
// One-of-N chips: every option visible, one tap to switch, the row wraps on
|
|
7
|
+
// narrow widths. Quiet zinc styling — bordered white at rest, dark fill when
|
|
8
|
+
// active (color stays reserved for status + primary actions). Two jobs, one
|
|
9
|
+
// control:
|
|
10
|
+
// - a SMALL, HOT FILTER the user flips between constantly ("narrow this
|
|
11
|
+
// view") — model the unfiltered state as an explicit option (e.g. "All").
|
|
12
|
+
// - a small REQUIRED single-select in a form / composer (a call outcome, a
|
|
13
|
+
// 1–N grade) — pills keep every choice in view and entry to one tap; an
|
|
14
|
+
// empty initial value (nothing chosen yet) is fine here.
|
|
15
|
+
// Reach for `Picker` when options are many or space is tight (it hides the set
|
|
16
|
+
// behind a click); for `RadioPicker` when the radio-circle "this writes"
|
|
17
|
+
// affordance suits a denser form. It's affordance + density, not
|
|
18
|
+
// filter-vs-write — the same ChipGroup serves both.
|
|
17
19
|
|
|
18
20
|
export interface ChipOption<T extends string = string> {
|
|
19
21
|
label: string;
|
|
@@ -29,8 +31,9 @@ export interface ChipGroupProps<T extends string = string> {
|
|
|
29
31
|
*/
|
|
30
32
|
accessibilityLabel: string;
|
|
31
33
|
options: ChipOption<T>[];
|
|
32
|
-
/** The active option
|
|
33
|
-
*
|
|
34
|
+
/** The active option. As a FILTER, model the unfiltered state as an explicit
|
|
35
|
+
* option (e.g. "All"); as a FORM selector, an empty value (nothing chosen
|
|
36
|
+
* yet) is valid. */
|
|
34
37
|
value: T;
|
|
35
38
|
onValueChange: (value: T) => void;
|
|
36
39
|
}
|
package/src/clarify.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { colors, solid, tint } from "./colors";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
|
|
7
|
+
export interface ClarifyOption {
|
|
8
|
+
label: string;
|
|
9
|
+
value: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ClarifyProps {
|
|
13
|
+
/** The agent's question — what it needs settled to proceed. */
|
|
14
|
+
question: string;
|
|
15
|
+
options: ClarifyOption[];
|
|
16
|
+
onAnswer: (value: string) => void;
|
|
17
|
+
/** Once answered, the card settles showing the chosen reply (pass the option
|
|
18
|
+
* label). */
|
|
19
|
+
answer?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The agent asks BACK — a question with quick-reply options the human picks
|
|
24
|
+
* before the run continues. Human-in-the-loop input: when the agent is unsure,
|
|
25
|
+
* it clarifies instead of guessing wrong. The run pauses on this; `onAnswer`
|
|
26
|
+
* resumes it. Pair with `AgentRun` / `AgentProgress`.
|
|
27
|
+
*/
|
|
28
|
+
export function Clarify(props: ClarifyProps) {
|
|
29
|
+
const answered = props.answer != null;
|
|
30
|
+
return (
|
|
31
|
+
<View style={styles.card}>
|
|
32
|
+
<View style={styles.header}>
|
|
33
|
+
<Icon name="message-circle-question-mark" size={15} color={solid("violet")} />
|
|
34
|
+
<Text size="sm" weight="medium" style={{ flex: 1 }}>
|
|
35
|
+
{props.question}
|
|
36
|
+
</Text>
|
|
37
|
+
</View>
|
|
38
|
+
{answered ? (
|
|
39
|
+
<View style={styles.answered}>
|
|
40
|
+
<Icon name="circle-check" size={14} color={solid("emerald")} />
|
|
41
|
+
<Text size="sm" color="muted">
|
|
42
|
+
{props.answer}
|
|
43
|
+
</Text>
|
|
44
|
+
</View>
|
|
45
|
+
) : (
|
|
46
|
+
<View style={styles.options}>
|
|
47
|
+
{props.options.map((o) => (
|
|
48
|
+
<Button key={o.value} title={o.label} color="secondary" shape="rounded" onPress={() => props.onAnswer(o.value)} />
|
|
49
|
+
))}
|
|
50
|
+
</View>
|
|
51
|
+
)}
|
|
52
|
+
</View>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const styles = StyleSheet.create({
|
|
57
|
+
card: {
|
|
58
|
+
borderWidth: 1,
|
|
59
|
+
borderColor: tint("violet", 0.35),
|
|
60
|
+
backgroundColor: colors.white,
|
|
61
|
+
borderRadius: 12,
|
|
62
|
+
padding: 14,
|
|
63
|
+
gap: 12,
|
|
64
|
+
},
|
|
65
|
+
header: { flexDirection: "row", alignItems: "flex-start", gap: 8 },
|
|
66
|
+
options: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
|
67
|
+
answered: { flexDirection: "row", alignItems: "center", gap: 6 },
|
|
68
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { isColorName, asColorName } from "./colors";
|
|
3
|
+
|
|
4
|
+
describe("isColorName", () => {
|
|
5
|
+
it("accepts palette family names", () => {
|
|
6
|
+
expect(isColorName("blue")).toBe(true);
|
|
7
|
+
expect(isColorName("emerald")).toBe(true);
|
|
8
|
+
expect(isColorName("zinc")).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("rejects role keys and scalar colors (not selectable families)", () => {
|
|
12
|
+
expect(isColorName("border")).toBe(false);
|
|
13
|
+
expect(isColorName("border_shadow")).toBe(false);
|
|
14
|
+
expect(isColorName("background")).toBe(false);
|
|
15
|
+
expect(isColorName("shadow")).toBe(false);
|
|
16
|
+
expect(isColorName("black")).toBe(false);
|
|
17
|
+
expect(isColorName("white")).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("rejects unknown tokens and non-strings", () => {
|
|
21
|
+
expect(isColorName("chartreuse")).toBe(false);
|
|
22
|
+
expect(isColorName("")).toBe(false);
|
|
23
|
+
expect(isColorName(undefined)).toBe(false);
|
|
24
|
+
expect(isColorName(null)).toBe(false);
|
|
25
|
+
expect(isColorName(42)).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("asColorName", () => {
|
|
30
|
+
it("passes a valid family through", () => {
|
|
31
|
+
expect(asColorName("purple")).toBe("purple");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("degrades an unknown/absent token to the neutral default", () => {
|
|
35
|
+
// The graceful-degradation contract: a select option whose color token this
|
|
36
|
+
// build doesn't recognize, or none at all, renders neutral rather than break.
|
|
37
|
+
expect(asColorName("chartreuse")).toBe("zinc");
|
|
38
|
+
expect(asColorName(undefined)).toBe("zinc");
|
|
39
|
+
expect(asColorName(null)).toBe("zinc");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("honors a custom fallback", () => {
|
|
43
|
+
expect(asColorName(undefined, "slate")).toBe("slate");
|
|
44
|
+
});
|
|
45
|
+
});
|
package/src/colors.ts
CHANGED
|
@@ -359,3 +359,28 @@ export function ramp(name: ColorName, count: number): string[] {
|
|
|
359
359
|
return scale[RAMP_STOPS[idx]];
|
|
360
360
|
});
|
|
361
361
|
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Is `value` a usable {@link ColorName} — a palette FAMILY, not a role key
|
|
365
|
+
* (`border`/`background`/…) and not `black`/`white`? A family resolves to a
|
|
366
|
+
* shade object; the role/scalar keys resolve to a string, so "value is an
|
|
367
|
+
* object" is the test (no name list to keep in sync).
|
|
368
|
+
*/
|
|
369
|
+
export function isColorName(value: unknown): value is ColorName {
|
|
370
|
+
return (
|
|
371
|
+
typeof value === "string" &&
|
|
372
|
+
value in colors &&
|
|
373
|
+
typeof (colors as Record<string, unknown>)[value] === "object"
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Coerce an arbitrary color token to a {@link ColorName}, falling back to a
|
|
379
|
+
* neutral. The single graceful-degradation point for stored option/status
|
|
380
|
+
* colors: a select option's `color` may be a token a newer table config
|
|
381
|
+
* introduced that this UI build predates, or absent entirely — either way a
|
|
382
|
+
* component renders a neutral badge instead of breaking. Used by `OptionBadge`.
|
|
383
|
+
*/
|
|
384
|
+
export function asColorName(value: unknown, fallback: ColorName = "zinc"): ColorName {
|
|
385
|
+
return isColorName(value) ? value : fallback;
|
|
386
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Badge } from "./badge";
|
|
2
|
+
import type { ColorName } from "./colors";
|
|
3
|
+
|
|
4
|
+
export type ConfidenceLevel = "high" | "medium" | "low";
|
|
5
|
+
|
|
6
|
+
export interface ConfidenceProps {
|
|
7
|
+
/** Pass a level directly, or a 0–1 `score` (≥0.8 high · ≥0.5 medium · else low). */
|
|
8
|
+
level?: ConfidenceLevel;
|
|
9
|
+
score?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const LABEL: Record<ConfidenceLevel, string> = { high: "High", medium: "Medium", low: "Low" };
|
|
13
|
+
// low is zinc (unsure, NOT an error — an error is a different signal); high
|
|
14
|
+
// emerald, medium amber. One family per level, weights derive from the name.
|
|
15
|
+
const COLOR: Record<ConfidenceLevel, ColorName> = { high: "emerald", medium: "amber", low: "zinc" };
|
|
16
|
+
|
|
17
|
+
export function levelFromScore(score: number): ConfidenceLevel {
|
|
18
|
+
if (score >= 0.8) return "high";
|
|
19
|
+
if (score >= 0.5) return "medium";
|
|
20
|
+
return "low";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How sure the AI is — a calibrated high/medium/low chip on the kit's `Badge`
|
|
25
|
+
* (dot variant). The human weights an AI proposal or estimate by it before
|
|
26
|
+
* accepting. Pair with `Suggestion` and `AssistedField`.
|
|
27
|
+
*/
|
|
28
|
+
export function Confidence(props: ConfidenceProps) {
|
|
29
|
+
const level = props.level ?? (props.score != null ? levelFromScore(props.score) : "medium");
|
|
30
|
+
return <Badge variant="dot" color={COLOR[level]} label={LABEL[level]} />;
|
|
31
|
+
}
|
package/src/file_dropzone.tsx
CHANGED
|
@@ -20,8 +20,9 @@ export interface FileDropzoneProps {
|
|
|
20
20
|
hint?: string;
|
|
21
21
|
/** Main line while a drag hovers the zone ("Thả để tải lên"). */
|
|
22
22
|
dropLabel?: string;
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
23
|
+
/** Minimum zone height — it grows to fit the icon + labels + padding, so a
|
|
24
|
+
* small value never crops the content. Size it to the surface (compact ~120 in
|
|
25
|
+
* a form field, 200+ as a screen's main affordance). Default 160. */
|
|
25
26
|
height?: number;
|
|
26
27
|
disabled?: boolean;
|
|
27
28
|
accessibilityLabel?: string;
|
|
@@ -127,7 +128,7 @@ export function FileDropzone(props: FileDropzoneProps) {
|
|
|
127
128
|
onHoverOut={() => setHovered(false)}
|
|
128
129
|
style={[
|
|
129
130
|
styles.zone,
|
|
130
|
-
{ height },
|
|
131
|
+
{ minHeight: height },
|
|
131
132
|
hovered && !dragging ? styles.zoneHovered : null,
|
|
132
133
|
dragging ? styles.zoneDragging : null,
|
|
133
134
|
disabled ? styles.zoneDisabled : null,
|
|
@@ -160,6 +161,7 @@ const styles = StyleSheet.create({
|
|
|
160
161
|
borderColor: colors.zinc[300],
|
|
161
162
|
backgroundColor: colors.zinc[50],
|
|
162
163
|
paddingHorizontal: 20,
|
|
164
|
+
paddingVertical: 16,
|
|
163
165
|
},
|
|
164
166
|
zoneHovered: {
|
|
165
167
|
borderColor: colors.zinc[400],
|
package/src/inline_edit.tsx
CHANGED
|
@@ -102,8 +102,10 @@ interface InlineEditFrameProps {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
interface InlineEditViewProps {
|
|
105
|
-
/** The
|
|
106
|
-
|
|
105
|
+
/** The current value: a formatted string (empty → placeholder), or a node
|
|
106
|
+
* rendered as-is — e.g. a `MemberChip` / `OptionBadge` for a rich resting
|
|
107
|
+
* value. A node takes the flex slot; the trailing adornment still right-aligns. */
|
|
108
|
+
display: string | ReactNode;
|
|
107
109
|
placeholder?: string;
|
|
108
110
|
onPress?: () => void;
|
|
109
111
|
disabled?: boolean;
|
|
@@ -136,9 +138,13 @@ export function InlineEditView(props: InlineEditViewProps) {
|
|
|
136
138
|
userSelect="none"
|
|
137
139
|
style={[styles.view, active && styles.viewActive]}
|
|
138
140
|
>
|
|
139
|
-
|
|
140
|
-
{display
|
|
141
|
-
|
|
141
|
+
{typeof display === "string" ? (
|
|
142
|
+
<Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder]}>
|
|
143
|
+
{display || placeholder || "—"}
|
|
144
|
+
</Text>
|
|
145
|
+
) : (
|
|
146
|
+
<View style={styles.viewNode}>{display}</View>
|
|
147
|
+
)}
|
|
142
148
|
{trailing != null ? trailing : null}
|
|
143
149
|
</PressableHighlight>
|
|
144
150
|
);
|
|
@@ -230,6 +236,7 @@ const styles = StyleSheet.create({
|
|
|
230
236
|
boxShadow: ACTIVE_RING,
|
|
231
237
|
},
|
|
232
238
|
placeholder: { color: colors.zinc[400] },
|
|
239
|
+
viewNode: { flex: 1, minWidth: 0 },
|
|
233
240
|
editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
|
|
234
241
|
editControl: { flex: 1, position: "relative" },
|
|
235
242
|
savingOverlay: { position: "absolute", right: 8, top: 0, bottom: 0, justifyContent: "center" },
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import { InlineSelect } from "./inline_select";
|
|
3
|
+
import { MemberChip } from "./member_chip";
|
|
4
|
+
import type { MemberSelectMember } from "./member_select";
|
|
5
|
+
import type { PickerOption } from "./picker";
|
|
6
|
+
|
|
7
|
+
interface InlineMemberSelectProps {
|
|
8
|
+
/** The candidate roster — an app feeds `useMembers()`, the product any
|
|
9
|
+
* directory. Each member carries its own avatar (`image`), so the resting
|
|
10
|
+
* chip and every option render with no side lookup. */
|
|
11
|
+
members: MemberSelectMember[];
|
|
12
|
+
/** The currently-assigned member id, or `null` when unassigned. A value not
|
|
13
|
+
* in `members` (a removed/deactivated member) reads as empty → placeholder. */
|
|
14
|
+
value: string | null;
|
|
15
|
+
/** Commit a new assignment (the picked member id). Throwing surfaces the
|
|
16
|
+
* inline error and reverts, like every inline editor. */
|
|
17
|
+
onSave: (memberId: string) => void | Promise<void>;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
accessibilityLabel?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Edit a `select_member` field in place: the assigned member shows as a
|
|
25
|
+
* {@link MemberChip} (avatar + name) at the kit's control height; clicking it
|
|
26
|
+
* floats a member picker (every option a `MemberChip`) and picking commits — no
|
|
27
|
+
* layout shift. The inline-edit twin of {@link MemberSelect} (use that in a
|
|
28
|
+
* form); built on {@link InlineSelect}. PURE: pass the candidate `members` (an
|
|
29
|
+
* app feeds `useMembers`); this fetches nothing.
|
|
30
|
+
*
|
|
31
|
+
* ```tsx
|
|
32
|
+
* const { members } = useMembers();
|
|
33
|
+
* <InlineMemberSelect members={members} value={row.assignee} onSave={saveAssignee} />
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function InlineMemberSelect(props: InlineMemberSelectProps) {
|
|
37
|
+
const { members, ...inline } = props;
|
|
38
|
+
|
|
39
|
+
const byId = useMemo(() => new Map(members.map((m) => [m.id, m])), [members]);
|
|
40
|
+
const options = useMemo<PickerOption<string>[]>(
|
|
41
|
+
() => members.map((m) => ({ value: m.id, label: m.name || m.email || m.id })),
|
|
42
|
+
[members],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const renderMember = useCallback(
|
|
46
|
+
(option: PickerOption<string>) => {
|
|
47
|
+
const member = byId.get(option.value);
|
|
48
|
+
if (!member) return null;
|
|
49
|
+
return <MemberChip name={member.name || member.email} image={member.image} />;
|
|
50
|
+
},
|
|
51
|
+
[byId],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<InlineSelect<string>
|
|
56
|
+
options={options}
|
|
57
|
+
renderOptionContent={renderMember}
|
|
58
|
+
renderValue={renderMember}
|
|
59
|
+
{...inline}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
package/src/inline_select.tsx
CHANGED
|
@@ -16,6 +16,10 @@ export interface InlineSelectProps<T extends string> {
|
|
|
16
16
|
/** Custom option content in the dropdown (icon + label, two-line, a badge…).
|
|
17
17
|
* Omit for a plain label list — both render through the same `PickerMenu`. */
|
|
18
18
|
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
19
|
+
/** Render the SELECTED value in the resting view as a node (an avatar chip, a
|
|
20
|
+
* colored badge) instead of its plain label. Falls back to the label when
|
|
21
|
+
* omitted; the empty state always shows the placeholder. */
|
|
22
|
+
renderValue?: (selected: PickerOption<T>) => React.ReactNode;
|
|
19
23
|
placeholder?: string;
|
|
20
24
|
disabled?: boolean;
|
|
21
25
|
accessibilityLabel?: string;
|
|
@@ -29,7 +33,7 @@ export interface InlineSelectProps<T extends string> {
|
|
|
29
33
|
* standard and custom-rendered pickers are both supported here.
|
|
30
34
|
*/
|
|
31
35
|
export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
32
|
-
const { value, onSave, options, renderOptionContent, placeholder, disabled, accessibilityLabel } = props;
|
|
36
|
+
const { value, onSave, options, renderOptionContent, renderValue, placeholder, disabled, accessibilityLabel } = props;
|
|
33
37
|
const [open, setOpen] = useState(false);
|
|
34
38
|
const [saving, setSaving] = useState(false);
|
|
35
39
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -58,7 +62,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
|
58
62
|
<Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
|
|
59
63
|
<PopoverTrigger>
|
|
60
64
|
<InlineEditView
|
|
61
|
-
display={selected
|
|
65
|
+
display={selected ? (renderValue ? renderValue(selected) : (selected.label ?? "")) : ""}
|
|
62
66
|
placeholder={placeholder}
|
|
63
67
|
disabled={disabled}
|
|
64
68
|
active={open && !disabled}
|