@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 +2 -1
- package/src/avatar.web.tsx +102 -0
- 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
|
+
"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
|
+
});
|
package/src/composer.tsx
ADDED
|
@@ -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
|
+
});
|