@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.
@@ -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
+ });
@@ -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
- // Exclusive filter chips the view-control sibling of the form-input
7
- // selectors. Picking between the one-of-N controls:
8
- // - ChipGroup: a SMALL, HOT filter set (≤ ~10 short options) the user flips
9
- // between constantly — every option stays visible, switching is one tap,
10
- // the row wraps on narrow widths. Reads as "narrow this view".
11
- // - Picker: many options or tight space compact, but hides the set behind
12
- // a click. Reads as "choose a value".
13
- // - RadioPicker: a form input that SETS data on a record (radio circles
14
- // signal "this writes"), not a view filter.
15
- // Chips carry quiet zinc styling: bordered white at rest, dark fill when
16
- // active color stays reserved for status semantics and primary actions.
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 exactly one; include an explicit "all" option for
33
- * the unfiltered state rather than modelling it as no selection. */
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
  }
@@ -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
+ }
@@ -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
- /** Zone height — size it to the surface (compact 120 in a form field,
24
- * 200+ as a screen's main affordance). Default 160. */
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],
@@ -102,8 +102,10 @@ interface InlineEditFrameProps {
102
102
  }
103
103
 
104
104
  interface InlineEditViewProps {
105
- /** The formatted current value. Empty → placeholder. */
106
- display: string;
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
- <Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder]}>
140
- {display || placeholder || "—"}
141
- </Text>
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
+ }
@@ -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?.label ?? ""}
65
+ display={selected ? (renderValue ? renderValue(selected) : (selected.label ?? "")) : ""}
62
66
  placeholder={placeholder}
63
67
  disabled={disabled}
64
68
  active={open && !disabled}