@lotics/ui 2.3.2 → 2.4.1

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.2",
3
+ "version": "2.4.1",
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,102 @@
1
+ import type { ImageContentFit, ImageSource } from "expo-image";
2
+ import { Image, View, StyleSheet, StyleProp, ViewStyle, ImageStyle } from "react-native";
3
+ import { Text } from "./text";
4
+ import { colors } from "./colors";
5
+
6
+ interface AvatarProps {
7
+ size?: number;
8
+ source?: ImageSource;
9
+ name?: string;
10
+ style?: StyleProp<ViewStyle | ImageStyle>;
11
+ contentFit?: ImageContentFit;
12
+ /**
13
+ * When true, the avatar announces its `name` to assistive tech. Default
14
+ * false because avatars almost always appear adjacent to the name text —
15
+ * announcing the image as well would double-read. Pass `announce` when the
16
+ * avatar is standalone (with no visible name nearby).
17
+ */
18
+ announce?: boolean;
19
+ }
20
+
21
+ /**
22
+ * Web Avatar. The native `avatar.tsx` renders through `expo-image`, a native
23
+ * module that pulls expo's runtime into the graph and fails to load under
24
+ * pure-web bundlers (Vite dev throws on its CJS interop; vitest can't resolve
25
+ * expo's winter runtime). On web, `react-native`'s `Image` (→ react-native-web
26
+ * → `<img>`) renders the same circular avatar with none of that cost. The prop
27
+ * surface is identical — `ImageSource`/`ImageContentFit` are kept as erased
28
+ * type-only imports so no expo-image module is ever loaded.
29
+ */
30
+ export function Avatar(props: AvatarProps) {
31
+ const { source, size = 32, name = "Unknown", style, contentFit, announce } = props;
32
+ const decorative = !announce;
33
+
34
+ if (!source || !source.uri) {
35
+ return (
36
+ <View
37
+ accessible={!decorative}
38
+ accessibilityLabel={decorative ? undefined : name}
39
+ accessibilityElementsHidden={decorative}
40
+ importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
41
+ aria-hidden={decorative || undefined}
42
+ style={[
43
+ styles.base,
44
+ { backgroundColor: colors.blue["600"], width: size, height: size },
45
+ style,
46
+ ]}
47
+ >
48
+ {/* Initials are a visual shorthand for the name; the accessible name is
49
+ on the container so the SR does not read "HM" in addition. */}
50
+ <Text
51
+ userSelect="none"
52
+ size="xs"
53
+ weight="medium"
54
+ color="inverted"
55
+ accessibilityElementsHidden
56
+ importantForAccessibility="no-hide-descendants"
57
+ aria-hidden
58
+ >
59
+ {getInitials(name, size)}
60
+ </Text>
61
+ </View>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <Image
67
+ accessibilityLabel={decorative ? undefined : name}
68
+ accessibilityElementsHidden={decorative}
69
+ importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
70
+ style={[styles.base, { width: size, height: size }, style as ImageStyle]}
71
+ source={{ uri: source.uri }}
72
+ resizeMode={contentFit === "contain" ? "contain" : "cover"}
73
+ />
74
+ );
75
+ }
76
+
77
+ function getInitials(name: string, size?: number): string {
78
+ let initials = 2;
79
+
80
+ if (size && size <= 32) {
81
+ initials = 1;
82
+
83
+ if (name.length <= 2) {
84
+ return name;
85
+ }
86
+ }
87
+
88
+ return name
89
+ .split(" ")
90
+ .map((c) => c.charAt(0).toUpperCase())
91
+ .slice(0, initials)
92
+ .join("");
93
+ }
94
+
95
+ const styles = StyleSheet.create({
96
+ base: {
97
+ borderRadius: 999,
98
+ justifyContent: "center",
99
+ alignItems: "center",
100
+ userSelect: "none",
101
+ },
102
+ });
@@ -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
+ });