@lotics/ui 2.3.2 → 2.4.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.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. package/src/composer.tsx +231 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "2.3.2",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -55,6 +55,7 @@
55
55
  "./button": "./src/button.tsx",
56
56
  "./checkbox": "./src/checkbox.tsx",
57
57
  "./combobox": "./src/combobox.tsx",
58
+ "./composer": "./src/composer.tsx",
58
59
  "./portal": "./src/portal.tsx",
59
60
  "./popover_nav": "./src/popover_nav.tsx",
60
61
  "./popover": "./src/popover.tsx",
@@ -0,0 +1,231 @@
1
+ import React, { type ReactNode, type Ref, useCallback, useState } from "react";
2
+ import { View, TextInput as RNTextInput, ScrollView, StyleSheet, type TextInputProps } from "react-native";
3
+ import { Button } from "./button";
4
+ import { colors } from "./colors";
5
+ import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
6
+ import { useAutoGrowHeight } from "./use_auto_grow_height";
7
+
8
+ /**
9
+ * The pure composer *chrome* — a bordered, auto-growing multiline text input
10
+ * with Enter-to-send, a send/stop button, and a footer. Everything domain- or
11
+ * Lotics-specific is injected: labels (no i18n), and `pills` / `files` /
12
+ * `children` slots the consumer fills (the product's chat wraps this and fills
13
+ * them with skill/knowledge pills + an upload grid; a custom-code app fills
14
+ * `files` with its own attachment region, or leaves it empty).
15
+ *
16
+ * Pure by the package contract: no i18n, no domain types, no upload machinery.
17
+ * Compose those above it.
18
+ */
19
+ export interface ComposerProps {
20
+ onSend: (text: string) => void;
21
+ onStop?: () => void;
22
+ disabled?: boolean;
23
+ /** Override the default "block on empty text" send-disable (e.g. allow send with attachments only). */
24
+ sendDisabled?: boolean;
25
+ placeholder?: string;
26
+ autoFocus?: boolean;
27
+ testID?: string;
28
+ /** Control text externally; omit to let the composer manage its own state. */
29
+ value?: string;
30
+ onChangeText?: (text: string) => void;
31
+ /** Extra TextInput props (ref, onKeyPress, …). `onKeyPress` runs before the
32
+ * built-in Enter-to-send and can `preventDefault()` to suppress it. */
33
+ textInputProps?: Partial<TextInputProps> & { ref?: Ref<RNTextInput> };
34
+
35
+ // Slots, rendered top-to-bottom above the input:
36
+ /** A row of context pills (skills, mentions, …). */
37
+ pills?: ReactNode;
38
+ /** An attachment region (an upload grid, a thumbnail row, …). */
39
+ files?: ReactNode;
40
+ /** Arbitrary content between the slots and the input. */
41
+ children?: ReactNode;
42
+ /** Footer left — typically an attach / actions button. */
43
+ actionsButton?: ReactNode;
44
+ /** Footer right, before the send button — e.g. a model picker. */
45
+ footerRight?: ReactNode;
46
+
47
+ /** Accessible/tooltip label for the send button. */
48
+ sendLabel?: string;
49
+ /** Accessible/tooltip label for the stop button (shown while `disabled` + `onStop`). */
50
+ stopLabel?: string;
51
+ }
52
+
53
+ export function Composer(props: ComposerProps) {
54
+ const {
55
+ onSend,
56
+ onStop,
57
+ disabled,
58
+ sendDisabled,
59
+ placeholder,
60
+ autoFocus,
61
+ testID,
62
+ textInputProps,
63
+ pills,
64
+ files,
65
+ children,
66
+ actionsButton,
67
+ footerRight,
68
+ sendLabel = "Send",
69
+ stopLabel = "Stop",
70
+ } = props;
71
+
72
+ const [internalText, setInternalText] = useState("");
73
+ const isControlled = props.value !== undefined;
74
+ const text = isControlled ? props.value! : internalText;
75
+ const setText = isControlled ? (props.onChangeText ?? setInternalText) : setInternalText;
76
+
77
+ const [focused, setFocused] = useState(false);
78
+ const { containerHeight, scrollEnabled, inputRef, measure, onContentSizeChange } = useAutoGrowHeight({ maxLines: 12 });
79
+ const emptyText = text.trim().length === 0;
80
+
81
+ const { ref: externalRef, ...spreadInputProps } = textInputProps ?? {};
82
+ const mergedInputRef = useCallback(
83
+ (node: RNTextInput | null) => {
84
+ inputRef(node);
85
+ if (typeof externalRef === "function") (externalRef as (node: RNTextInput | null) => void)(node);
86
+ else if (externalRef) (externalRef as React.MutableRefObject<RNTextInput | null>).current = node;
87
+ },
88
+ [inputRef, externalRef],
89
+ );
90
+
91
+ const effectiveSendDisabled = sendDisabled !== undefined ? sendDisabled : emptyText;
92
+
93
+ const handleSend = useCallback(() => {
94
+ if (disabled || effectiveSendDisabled) return;
95
+ onSend(text);
96
+ setText("");
97
+ requestAnimationFrame(measure);
98
+ }, [disabled, effectiveSendDisabled, onSend, text, setText, measure]);
99
+
100
+ const handleChangeText = useCallback(
101
+ (t: string) => {
102
+ setText(t);
103
+ measure();
104
+ },
105
+ [setText, measure],
106
+ );
107
+
108
+ const handleKeyPress = useCallback(
109
+ (e: unknown) => {
110
+ // The consumer's handler runs first and may suppress send (e.g. to remove
111
+ // a pill on Backspace, or run a mention selection on Enter).
112
+ if (spreadInputProps.onKeyPress) {
113
+ (spreadInputProps.onKeyPress as (e: unknown) => void)(e);
114
+ }
115
+ const webEvent = e as { key: string; shiftKey: boolean; preventDefault: () => void; defaultPrevented?: boolean };
116
+ if (webEvent.defaultPrevented) return;
117
+ if (webEvent.key === "Enter" && !webEvent.shiftKey) {
118
+ webEvent.preventDefault();
119
+ handleSend();
120
+ }
121
+ },
122
+ [handleSend, spreadInputProps],
123
+ );
124
+
125
+ const hasContentAboveInput = pills != null || files != null || children != null;
126
+
127
+ return (
128
+ <View testID={testID} style={[styles.container, focused && styles.focused]}>
129
+ <View style={[styles.inputContainer, hasContentAboveInput && styles.inputContainerWithContent]}>
130
+ {pills != null && <View style={styles.pillRow}>{pills}</View>}
131
+ {files != null && (
132
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8 }}>
133
+ {files}
134
+ </ScrollView>
135
+ )}
136
+ {children}
137
+ <View style={{ justifyContent: "center" }}>
138
+ <View style={{ height: containerHeight }}>
139
+ <RNTextInput
140
+ ref={mergedInputRef}
141
+ autoFocus={autoFocus}
142
+ onChangeText={handleChangeText}
143
+ onSubmitEditing={handleSend}
144
+ placeholder={placeholder}
145
+ placeholderTextColor={colors.zinc[500]}
146
+ value={text}
147
+ onFocus={() => setFocused(true)}
148
+ onBlur={() => setFocused(false)}
149
+ multiline
150
+ scrollEnabled={scrollEnabled}
151
+ onContentSizeChange={onContentSizeChange}
152
+ {...spreadInputProps}
153
+ onKeyPress={handleKeyPress}
154
+ style={[
155
+ styles.textInput,
156
+ getInputTextStyle(),
157
+ { outlineStyle: "none" },
158
+ !scrollEnabled && { overflow: "hidden" as const },
159
+ spreadInputProps?.style,
160
+ ]}
161
+ />
162
+ </View>
163
+ </View>
164
+ </View>
165
+
166
+ <View style={styles.footer}>
167
+ <View style={styles.footerLeft}>{actionsButton}</View>
168
+ <View style={styles.footerRight}>
169
+ {footerRight}
170
+ {disabled && onStop ? (
171
+ <Button onPress={onStop} color="primary" icon="square" tooltip={stopLabel} />
172
+ ) : (
173
+ <Button
174
+ onPress={handleSend}
175
+ color="primary"
176
+ disabled={disabled || effectiveSendDisabled}
177
+ icon="arrow-up"
178
+ tooltip={sendLabel}
179
+ />
180
+ )}
181
+ </View>
182
+ </View>
183
+ </View>
184
+ );
185
+ }
186
+
187
+ const styles = StyleSheet.create({
188
+ container: {
189
+ padding: 8,
190
+ borderRadius: 16,
191
+ borderWidth: 2,
192
+ borderColor: colors.border,
193
+ backgroundColor: colors.background,
194
+ },
195
+ focused: {
196
+ borderColor: colors.black,
197
+ },
198
+ footer: {
199
+ flex: 1,
200
+ flexDirection: "row",
201
+ gap: 8,
202
+ justifyContent: "space-between",
203
+ zIndex: 1,
204
+ paddingTop: 8,
205
+ },
206
+ footerLeft: {
207
+ flexDirection: "row",
208
+ gap: 8,
209
+ },
210
+ footerRight: {
211
+ flexDirection: "row",
212
+ alignItems: "center",
213
+ gap: 8,
214
+ },
215
+ inputContainer: {
216
+ padding: 4,
217
+ },
218
+ inputContainerWithContent: {
219
+ gap: 8,
220
+ },
221
+ pillRow: {
222
+ flexDirection: "row",
223
+ flexWrap: "wrap",
224
+ gap: 4,
225
+ },
226
+ textInput: {
227
+ flex: 1,
228
+ fontFamily: fontFamilyRegular,
229
+ letterSpacing: -0.4,
230
+ },
231
+ });