@lotics/ui 44.2.0 → 44.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.
@@ -108,6 +108,7 @@ Key props (full API: `../src/composer.tsx`):
108
108
  | `footerRight` | Footer-right content before Send (a model picker); forces expanded |
109
109
  | `maxLines` | Expanded growth cap before the input scrolls (default 10) |
110
110
  | `sendLabel` / `stopLabel` | Accessible/tooltip labels (default English "Send"/"Stop" — pass translations) |
111
+ | `highlightRanges` | Character ranges of `value` to tint. What a range MEANS is yours — the composer paints a ground behind those characters and nothing more. It renders a mirror BEHIND the real field rather than styling the field itself, so the browser stays the editor and IME, undo, paste and selection are untouched; omit the prop and no mirror is rendered at all |
111
112
  | `textInputProps` | Extra `TextInput` props; a consumer `onKeyPress` runs before Enter-to-send and can `preventDefault()` to suppress it |
112
113
 
113
114
  For a run that needs a FILE + a prompt together (attach a photo, review/remove it, add a note,
package/docs/catalog.md CHANGED
@@ -1599,6 +1599,11 @@ component rather than showing it at zero.
1599
1599
  - **`matrix_totals`** — the data layer for `Matrix`: `matrixTotals` (the cross-tab
1600
1600
  aggregation) + `MatrixAxisItem` / `MatrixCellRef` / `MatrixTotalsResult`; React-free, so a
1601
1601
  KPI can be driven off the same numbers the grid shows.
1602
+ - **`highlight_segments`** — `splitHighlightSegments`: cuts a string into plain and
1603
+ highlighted runs for `Composer`'s `highlightRanges`. React-free, and the reason it is
1604
+ separate: it feeds a mirror sitting behind a real text field, so a boundary off by one
1605
+ paints the tint adrift while everything still looks like a working composer. Overlapping,
1606
+ unsorted and out-of-bounds ranges all normalize; no input drops a character.
1602
1607
  - **`legend_item`** — `LegendItem`: one swatch + label of a chart legend.
1603
1608
  - **`remainder_meter`** — `RemainderMeter`: allocated-vs-remaining meter
1604
1609
  (`RemainderMeterLabels` localized via the provider).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "44.2.0",
3
+ "version": "44.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
package/src/composer.tsx CHANGED
@@ -1,9 +1,19 @@
1
- import React, { type ReactNode, type Ref, useCallback, useEffect, useState } from "react";
2
- import { View, TextInput as RNTextInput, ScrollView, StyleSheet, type TextInputProps } from "react-native";
1
+ import React, { type ReactNode, type Ref, useCallback, useEffect, useMemo, useState } from "react";
2
+ import {
3
+ View,
4
+ Text as RNText,
5
+ TextInput as RNTextInput,
6
+ ScrollView,
7
+ StyleSheet,
8
+ type NativeSyntheticEvent,
9
+ type TextInputProps,
10
+ type TextInputScrollEventData,
11
+ } from "react-native";
3
12
  import { IconButton } from "./icon_button";
4
13
  import { colors } from "./colors";
5
14
  import { Text } from "./text";
6
15
  import { fontFamilyRegular, getInputTextStyle, INPUT_LETTER_SPACING } from "./text_utils";
16
+ import { splitHighlightSegments, type HighlightRange } from "./highlight_segments";
7
17
  import { useAutoGrowHeight } from "./use_auto_grow_height";
8
18
  import { useLoticsLocale } from "./locale";
9
19
 
@@ -43,6 +53,10 @@ export interface ComposerProps {
43
53
  /** Control text externally; omit to let the composer manage its own state. */
44
54
  value?: string;
45
55
  onChangeText?: (text: string) => void;
56
+ /** Ranges of `value` to tint. What they MEAN is the caller's business — this
57
+ * package never learns what a skill or a document is. Omit it and nothing is
58
+ * rendered, so a consumer that never tints pays nothing. */
59
+ highlightRanges?: readonly HighlightRange[];
46
60
  /** Extra TextInput props (ref, onKeyPress, …). `onKeyPress` runs before the
47
61
  * built-in Enter-to-send and can `preventDefault()` to suppress it. */
48
62
  textInputProps?: Partial<TextInputProps> & { ref?: Ref<RNTextInput> };
@@ -79,6 +93,7 @@ export function Composer(props: ComposerProps) {
79
93
  autoFocus,
80
94
  testID,
81
95
  textInputProps,
96
+ highlightRanges,
82
97
  pills,
83
98
  files,
84
99
  children,
@@ -186,12 +201,85 @@ export function Composer(props: ComposerProps) {
186
201
  // padding; `measureScrollHeight` subtracts that padding, so wrap-detection is
187
202
  // unaffected. Growth past one line is the EXPANDED layout's job (up to maxLines).
188
203
  const compactPadV = Math.max(2, Math.round((COMPACT_INPUT_HEIGHT - lineHeight) / 2));
204
+
205
+ // Null — not an empty array — when there is nothing to tint, so the mirror is
206
+ // not rendered at all for the ordinary message or for a consumer that never
207
+ // passes ranges.
208
+ const highlightSegments = useMemo(
209
+ () =>
210
+ highlightRanges && highlightRanges.length > 0
211
+ ? splitHighlightSegments(text, highlightRanges)
212
+ : null,
213
+ [text, highlightRanges],
214
+ );
215
+ const [mirrorScrollY, setMirrorScrollY] = useState(0);
216
+ const handleInputScroll = useCallback(
217
+ (e: NativeSyntheticEvent<TextInputScrollEventData>) => {
218
+ // The two platforms report the offset in different places and neither has
219
+ // the other's: RN measures the content and sends `contentOffset`, while
220
+ // rn-web forwards the <textarea>'s DOM scroll event verbatim, where the
221
+ // only offset is the element's own `scrollTop`. Reading just the native
222
+ // shape type-checks — RN's types promise `contentOffset` — and then throws
223
+ // on the web, on every scroll tick, while the tint silently stops tracking
224
+ // the words in exactly the long message this handler exists to follow.
225
+ const native: Partial<TextInputScrollEventData> & { target?: { scrollTop?: number } } =
226
+ e.nativeEvent;
227
+ setMirrorScrollY(native.contentOffset?.y ?? native.target?.scrollTop ?? 0);
228
+ },
229
+ [],
230
+ );
189
231
  const field = (
190
232
  <View
191
233
  key="field"
192
234
  onLayout={measure}
193
235
  style={expanded ? { height: containerHeight } : [styles.field, { height: COMPACT_INPUT_HEIGHT }]}
194
236
  >
237
+ {highlightSegments ? (
238
+ // A MIRROR, not a replacement. The real <textarea> stays and keeps every
239
+ // behaviour the browser gives it — IME composition (this workspace types
240
+ // Vietnamese), undo, paste, spellcheck, selection, a11y — none of which
241
+ // survives a contentEditable rewrite. It renders TRANSPARENT text purely
242
+ // to lay out identically, and paints the tint behind the words the field
243
+ // is drawing on top; the field's own text and caret are untouched, so
244
+ // there is nothing here that can swallow a keystroke.
245
+ //
246
+ // Alignment is by construction: same text style object, same padding
247
+ // expressions, same width. It translates with the field's own scroll so
248
+ // the tint cannot drift once the message outgrows `maxLines`.
249
+ <View
250
+ pointerEvents="none"
251
+ style={[
252
+ StyleSheet.absoluteFill,
253
+ { overflow: "hidden" },
254
+ expanded ? null : { paddingHorizontal: 10, paddingVertical: compactPadV },
255
+ ]}
256
+ >
257
+ <RNText
258
+ testID="composer-highlight-mirror"
259
+ // The RAW react-native Text, never the kit's: `Text` bakes tracking
260
+ // per type-ramp rung, which quietly overrode the explicit
261
+ // `letterSpacing` the field uses and drifted the tint a fraction of
262
+ // a pixel per character — invisible on a short line, off the words
263
+ // by the end of a long one. The mirror takes the field's own style
264
+ // array and nothing else.
265
+ style={[
266
+ styles.textInput,
267
+ getInputTextStyle(),
268
+ { color: "transparent" },
269
+ { transform: [{ translateY: -mirrorScrollY }] },
270
+ ]}
271
+ >
272
+ {highlightSegments.map((segment, i) => (
273
+ <RNText
274
+ key={i}
275
+ style={segment.highlighted ? styles.highlightSegment : undefined}
276
+ >
277
+ {segment.text}
278
+ </RNText>
279
+ ))}
280
+ </RNText>
281
+ </View>
282
+ ) : null}
195
283
  <RNTextInput
196
284
  ref={mergedInputRef}
197
285
  autoFocus={autoFocus}
@@ -205,11 +293,19 @@ export function Composer(props: ComposerProps) {
205
293
  multiline
206
294
  scrollEnabled={expanded && scrollEnabled}
207
295
  onContentSizeChange={onContentSizeChange}
296
+ onScroll={highlightSegments ? handleInputScroll : undefined}
208
297
  {...spreadInputProps}
209
298
  onKeyPress={handleKeyPress}
210
299
  style={[
211
300
  styles.textInput,
212
301
  getInputTextStyle(),
302
+ // ABOVE the mirror. The mirror is absolutely positioned, and a
303
+ // positioned box paints over static in-flow content whatever the
304
+ // source order — so without this its tint is an opaque ground drawn
305
+ // ON TOP of the field, and the words inside a mention disappear
306
+ // entirely. Geometry and font metrics can all agree while this is
307
+ // wrong; only rendering the thing shows it.
308
+ { position: "relative" as const, zIndex: 1 },
213
309
  { outlineStyle: "none" },
214
310
  (!expanded || !scrollEnabled) && { overflow: "hidden" as const },
215
311
  expanded ? null : { paddingHorizontal: 10, paddingVertical: compactPadV },
@@ -348,4 +444,10 @@ const styles = StyleSheet.create({
348
444
  fontFamily: fontFamilyRegular,
349
445
  letterSpacing: INPUT_LETTER_SPACING,
350
446
  },
447
+ // A GROUND, not coloured text: the words on screen are the field's, drawn on
448
+ // top, so the tint has to sit behind them rather than replace them.
449
+ highlightSegment: {
450
+ backgroundColor: colors.blue[100],
451
+ borderRadius: 4,
452
+ },
351
453
  });
@@ -0,0 +1,61 @@
1
+ export interface HighlightRange {
2
+ /** Index of the first highlighted character. */
3
+ start: number;
4
+ /** Index one past the last highlighted character. */
5
+ end: number;
6
+ }
7
+
8
+ export interface HighlightSegment {
9
+ text: string;
10
+ highlighted: boolean;
11
+ }
12
+
13
+ /**
14
+ * Cut `value` into alternating plain and highlighted runs.
15
+ *
16
+ * Split out from the composer because it is the part that can be wrong in a way
17
+ * nobody sees: the mirror it feeds sits behind a real text field, so a segment
18
+ * boundary off by one paints the tint half a character adrift and everything
19
+ * still looks like a working composer. Ranges arrive from a caller that is
20
+ * matching text it does not control, so overlapping, unsorted, reversed and
21
+ * out-of-bounds inputs are all normal here — none of them may drop or duplicate
22
+ * a character, because the mirror has to lay out identically to the field it
23
+ * sits behind or the tint drifts from the words.
24
+ */
25
+ export function splitHighlightSegments(
26
+ value: string,
27
+ ranges: readonly HighlightRange[],
28
+ ): HighlightSegment[] {
29
+ if (value.length === 0) return [];
30
+
31
+ const clamped = ranges
32
+ .map((r) => ({
33
+ start: Math.max(0, Math.min(r.start, value.length)),
34
+ end: Math.max(0, Math.min(r.end, value.length)),
35
+ }))
36
+ .filter((r) => r.end > r.start)
37
+ .sort((a, b) => a.start - b.start);
38
+
39
+ // Overlaps merge rather than nest: two tints over one character would render
40
+ // twice as dark, and the caller's ranges are matches over shared text.
41
+ const merged: HighlightRange[] = [];
42
+ for (const range of clamped) {
43
+ const last = merged[merged.length - 1];
44
+ if (last && range.start <= last.end) last.end = Math.max(last.end, range.end);
45
+ else merged.push({ ...range });
46
+ }
47
+
48
+ const segments: HighlightSegment[] = [];
49
+ let cursor = 0;
50
+ for (const range of merged) {
51
+ if (range.start > cursor) {
52
+ segments.push({ text: value.slice(cursor, range.start), highlighted: false });
53
+ }
54
+ segments.push({ text: value.slice(range.start, range.end), highlighted: true });
55
+ cursor = range.end;
56
+ }
57
+ if (cursor < value.length) {
58
+ segments.push({ text: value.slice(cursor), highlighted: false });
59
+ }
60
+ return segments;
61
+ }