@lotics/ui 2.3.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "2.3.1",
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",
package/src/combobox.tsx CHANGED
@@ -389,7 +389,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
389
389
  selected={!multi && !isCustom && single?.value === opt.value}
390
390
  disabled={opt.disabled}
391
391
  onPress={() => handleSelect(i)}
392
- onHoverIn={() => setActiveIndex(i)}
392
+ onHoverIn={() => setActiveIndex(i, false)}
393
393
  />
394
394
  );
395
395
  })
@@ -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
+ });
@@ -103,4 +103,11 @@ describe("useListKeyboardNav", () => {
103
103
  act(() => void view.result.current.handleKey("ArrowDown"));
104
104
  expect(onActiveChange).toHaveBeenCalledWith(1);
105
105
  });
106
+
107
+ it("setActiveIndex moves the active row but skips onActiveChange when scrollIntoView is false (mouse hover)", () => {
108
+ const { view, onActiveChange } = setup();
109
+ act(() => view.result.current.setActiveIndex(2, false));
110
+ expect(view.result.current.activeIndex).toBe(2);
111
+ expect(onActiveChange).not.toHaveBeenCalled();
112
+ });
106
113
  });
@@ -17,7 +17,10 @@ export interface ListKeyboardNav {
17
17
  /** The virtually-focused row. The list stays visually highlighted here while
18
18
  * DOM focus remains on the controlling input (ARIA `aria-activedescendant`). */
19
19
  activeIndex: number;
20
- setActiveIndex: (index: number) => void;
20
+ /** Set the active row. `scrollIntoView` defaults to true (keyboard nav); pass
21
+ * false on mouse hover so it doesn't scroll the row into view — the cursor is
22
+ * already on it, and scrolling would slide a different row under the cursor. */
23
+ setActiveIndex: (index: number, scrollIntoView?: boolean) => void;
21
24
  /** Feed a key from the controlling input's `onKeyPress`. Returns true when it
22
25
  * handled the key, so the caller can `preventDefault()`. */
23
26
  handleKey: (key: string) => boolean;
@@ -34,9 +37,13 @@ export function useListKeyboardNav(opts: ListKeyboardNavOptions): ListKeyboardNa
34
37
  const [activeIndex, setActiveIndexState] = useState(0);
35
38
 
36
39
  const setActiveIndex = useCallback(
37
- (index: number) => {
40
+ (index: number, scrollIntoView = true) => {
38
41
  setActiveIndexState(index);
39
- onActiveChange?.(index);
42
+ // Only scroll for keyboard nav. Mouse hover sets the active row without
43
+ // scrolling — the cursor is already on it, and scrolling it into view
44
+ // would slide a different row under the cursor → onHoverIn → scroll →
45
+ // runaway loop.
46
+ if (scrollIntoView) onActiveChange?.(index);
40
47
  },
41
48
  [onActiveChange],
42
49
  );