@startsimpli/ui-native 0.1.1 → 0.1.2

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 (39) hide show
  1. package/README.md +13 -0
  2. package/package.json +2 -1
  3. package/src/__tests__/forms.test.tsx +169 -0
  4. package/src/__tests__/interactive.test.tsx +117 -0
  5. package/src/__tests__/lists.test.tsx +190 -0
  6. package/src/__tests__/logic-list.test.ts +88 -0
  7. package/src/__tests__/logic-markdown.test.ts +39 -0
  8. package/src/__tests__/logic-misc.test.ts +87 -0
  9. package/src/__tests__/styles-overlay.test.ts +14 -0
  10. package/src/components/ChatComposer.tsx +65 -0
  11. package/src/components/Chip.tsx +5 -1
  12. package/src/components/DataList.tsx +52 -0
  13. package/src/components/EmptyState.tsx +6 -1
  14. package/src/components/FormField.tsx +38 -0
  15. package/src/components/Header.tsx +46 -0
  16. package/src/components/ListControls.tsx +85 -0
  17. package/src/components/Markdown.tsx +93 -0
  18. package/src/components/MessageBubble.tsx +49 -0
  19. package/src/components/SearchBar.tsx +52 -0
  20. package/src/components/SectionedList.tsx +55 -0
  21. package/src/components/SegmentedControl.tsx +4 -1
  22. package/src/components/Select.tsx +95 -0
  23. package/src/components/Sheet.tsx +41 -0
  24. package/src/components/StatusChip.tsx +27 -0
  25. package/src/components/SwipeRow.tsx +85 -0
  26. package/src/components/TextArea.tsx +38 -0
  27. package/src/components/Toast.tsx +61 -0
  28. package/src/components/serverListTypes.ts +53 -0
  29. package/src/components/useServerList.tsx +58 -0
  30. package/src/index.ts +20 -0
  31. package/src/logic/chat.ts +3 -0
  32. package/src/logic/listState.ts +77 -0
  33. package/src/logic/markdown.ts +112 -0
  34. package/src/logic/select.ts +12 -0
  35. package/src/logic/status.ts +27 -0
  36. package/src/logic/swipe.ts +13 -0
  37. package/src/logic/toast.ts +28 -0
  38. package/src/styles-chat.ts +51 -0
  39. package/src/styles-overlay.ts +100 -0
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { clampSwipe, settleSwipe } from '../logic/swipe';
3
+ import { createToastStore } from '../logic/toast';
4
+ import { canSend } from '../logic/chat';
5
+ import { statusTone, formatProgress } from '../logic/status';
6
+ import { filterOptions, selectedLabel } from '../logic/select';
7
+
8
+ describe('swipe', () => {
9
+ it('clamps between -actionsWidth (open) and 0', () => {
10
+ expect(clampSwipe(-300, 160)).toBe(-160);
11
+ expect(clampSwipe(40, 160)).toBe(0);
12
+ expect(clampSwipe(-50, 160)).toBe(-50);
13
+ });
14
+ it('settles open past half or on a fast left fling, closed otherwise', () => {
15
+ expect(settleSwipe(-90, 0, 160)).toBe('open');
16
+ expect(settleSwipe(-30, -0.8, 160)).toBe('open');
17
+ expect(settleSwipe(-90, 0.8, 160)).toBe('closed');
18
+ expect(settleSwipe(-30, 0, 160)).toBe('closed');
19
+ });
20
+ });
21
+
22
+ describe('toast store', () => {
23
+ it('show/dismiss/clear notify subscribers', () => {
24
+ let n = 0;
25
+ const s = createToastStore();
26
+ s.subscribe(() => { n++; });
27
+ const a = s.show('Saved');
28
+ const b = s.show('Oops', { tone: 'error', durationMs: 0 });
29
+ expect(s.getToasts().map((t) => [t.id, t.message, t.tone])).toEqual([[a, 'Saved', 'info'], [b, 'Oops', 'error']]);
30
+ expect(s.getToasts()[0].durationMs).toBe(3000);
31
+ s.dismiss(a);
32
+ expect(s.getToasts()).toHaveLength(1);
33
+ s.clear();
34
+ expect(s.getToasts()).toEqual([]);
35
+ expect(n).toBe(4);
36
+ });
37
+ it('unsubscribe stops notifications; dismissing unknown id is a no-op', () => {
38
+ let n = 0;
39
+ const s = createToastStore();
40
+ const off = s.subscribe(() => { n++; });
41
+ off();
42
+ s.show('x'); s.dismiss('nope');
43
+ expect(n).toBe(0);
44
+ });
45
+ });
46
+
47
+ describe('canSend', () => {
48
+ it('needs non-blank text and not already sending', () => {
49
+ expect(canSend('hi', false)).toBe(true);
50
+ expect(canSend(' \n', false)).toBe(false);
51
+ expect(canSend('hi', true)).toBe(false);
52
+ expect(canSend('', false)).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe('status', () => {
57
+ it('maps domain statuses onto badge variants', () => {
58
+ expect(statusTone('queued')).toEqual({ variant: 'secondary', label: 'Queued' });
59
+ expect(statusTone('processing').variant).toBe('default');
60
+ expect(statusTone('Transcribing').variant).toBe('default');
61
+ expect(statusTone('done')).toEqual({ variant: 'success', label: 'Done' });
62
+ expect(statusTone('ready').variant).toBe('success');
63
+ expect(statusTone('failed')).toEqual({ variant: 'destructive', label: 'Failed' });
64
+ expect(statusTone('partial').variant).toBe('warning');
65
+ expect(statusTone('weird_thing')).toEqual({ variant: 'outline', label: 'Weird thing' });
66
+ });
67
+ it('formats progress as a clamped percent', () => {
68
+ expect(formatProgress(0.4)).toBe('40%');
69
+ expect(formatProgress(2)).toBe('100%');
70
+ expect(formatProgress(-1)).toBe('0%');
71
+ expect(formatProgress(Number.NaN)).toBe('0%');
72
+ });
73
+ });
74
+
75
+ describe('select', () => {
76
+ const opts = [{ value: 'a', label: 'Alpha', description: 'first' }, { value: 'b', label: 'Beta' }];
77
+ it('filters by label/description, case-insensitive', () => {
78
+ expect(filterOptions(opts, 'ALP')).toEqual([opts[0]]);
79
+ expect(filterOptions(opts, 'first')).toEqual([opts[0]]);
80
+ expect(filterOptions(opts, ' ')).toEqual(opts);
81
+ });
82
+ it('selectedLabel falls back to null', () => {
83
+ expect(selectedLabel(opts, 'b')).toBe('Beta');
84
+ expect(selectedLabel(opts, null)).toBeNull();
85
+ expect(selectedLabel(opts, 'zzz')).toBeNull();
86
+ });
87
+ });
@@ -0,0 +1,14 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { errorTextColor, toastStyle, swipeActionStyle } from '../styles-overlay';
3
+ import { lightTheme, darkTheme } from '../theme';
4
+
5
+ describe('styles-overlay', () => {
6
+ it('error text is the token in light and a brighter red in dark', () => {
7
+ expect(errorTextColor(lightTheme)).toBe(lightTheme.colors.destructive);
8
+ expect(errorTextColor(darkTheme)).not.toBe(darkTheme.colors.destructive);
9
+ });
10
+ it('toast and swipe actions follow the tone tokens', () => {
11
+ expect(toastStyle(lightTheme, 'success').backgroundColor).toBe(lightTheme.colors.success);
12
+ expect(swipeActionStyle(darkTheme, 'destructive', 80)).toMatchObject({ width: 80, backgroundColor: darkTheme.colors.destructive });
13
+ });
14
+ });
@@ -0,0 +1,65 @@
1
+ import { useState } from 'react';
2
+ import { KeyboardAvoidingView, Platform, Pressable, Text as RNText, View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { Input } from './Input';
4
+ import { useTheme } from './ThemeProvider';
5
+ import { composerStyle, sendButtonStyle, sendGlyphStyle } from '../styles-chat';
6
+ import { canSend } from '../logic/chat';
7
+
8
+ export interface ChatComposerProps {
9
+ onSend: (text: string) => void;
10
+ /** Controlled value. Omit to let the composer own (and clear) its text. */
11
+ value?: string;
12
+ onChangeText?: (text: string) => void;
13
+ placeholder?: string;
14
+ /** Disables sending while an answer is in flight. */
15
+ sending?: boolean;
16
+ /** Extra offset for headers above the keyboard-avoiding view. */
17
+ keyboardVerticalOffset?: number;
18
+ /** Bottom safe-area padding (home indicator). */
19
+ bottomInset?: number;
20
+ style?: StyleProp<ViewStyle>;
21
+ testID?: string;
22
+ }
23
+
24
+ /** Keyboard-avoiding message input with a send button. Trims text on send. */
25
+ export function ChatComposer({ onSend, value, onChangeText, placeholder = 'Message', sending = false, keyboardVerticalOffset = 0, bottomInset = 0, style, testID }: ChatComposerProps) {
26
+ const theme = useTheme();
27
+ const [own, setOwn] = useState('');
28
+ const controlled = value !== undefined;
29
+ const text = controlled ? value : own;
30
+ const enabled = canSend(text, sending);
31
+ const id = (s: string) => (testID ? `${testID}-${s}` : undefined);
32
+
33
+ const submit = () => {
34
+ if (!enabled) return;
35
+ onSend(text.trim());
36
+ if (!controlled) setOwn('');
37
+ };
38
+
39
+ return (
40
+ <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={keyboardVerticalOffset}>
41
+ <View style={[composerStyle(theme), { paddingBottom: bottomInset + theme.spacing.sm }, style]}>
42
+ <Input
43
+ testID={id('input')}
44
+ value={text}
45
+ onChangeText={(t) => { if (!controlled) setOwn(t); onChangeText?.(t); }}
46
+ placeholder={placeholder}
47
+ accessibilityLabel={placeholder}
48
+ multiline
49
+ style={{ flex: 1, maxHeight: 120, borderRadius: theme.radii.xl }}
50
+ />
51
+ <Pressable
52
+ testID={id('send')}
53
+ accessibilityRole="button"
54
+ accessibilityLabel="Send message"
55
+ accessibilityState={{ disabled: !enabled }}
56
+ disabled={!enabled}
57
+ onPress={submit}
58
+ style={sendButtonStyle(theme, enabled)}
59
+ >
60
+ <RNText style={sendGlyphStyle(theme, enabled)}>↑</RNText>
61
+ </Pressable>
62
+ </View>
63
+ </KeyboardAvoidingView>
64
+ );
65
+ }
@@ -7,17 +7,21 @@ export interface ChipProps {
7
7
  selected?: boolean;
8
8
  onPress?: () => void;
9
9
  style?: StyleProp<ViewStyle>;
10
+ testID?: string;
11
+ accessibilityLabel?: string;
10
12
  }
11
13
 
12
14
  /** Selectable filter pill. Filled with the primary color when selected,
13
15
  * outlined otherwise. Compose a row of these for a filter bar. */
14
- export function Chip({ label, selected = false, onPress, style }: ChipProps) {
16
+ export function Chip({ label, selected = false, onPress, style, testID, accessibilityLabel }: ChipProps) {
15
17
  const theme = useTheme();
16
18
  return (
17
19
  <Pressable
18
20
  accessibilityRole="button"
19
21
  accessibilityState={{ selected }}
20
22
  onPress={onPress}
23
+ testID={testID}
24
+ accessibilityLabel={accessibilityLabel}
21
25
  style={({ pressed }) => [chipStyle(theme, selected), pressed ? { opacity: 0.7 } : null, style]}
22
26
  >
23
27
  <RNText style={chipTextStyle(theme, selected)}>{label}</RNText>
@@ -0,0 +1,52 @@
1
+ import type { ReactElement } from 'react';
2
+ import { FlatList, View, type FlatListProps, type ListRenderItem } from 'react-native';
3
+ import { ListControls } from './ListControls';
4
+ import { useServerList } from './useServerList';
5
+ import { useTheme } from './ThemeProvider';
6
+ import type { ServerListProps } from './serverListTypes';
7
+
8
+ export type DataListProps<T> = ServerListProps &
9
+ Pick<FlatListProps<T>, 'keyExtractor' | 'extraData' | 'ItemSeparatorComponent' | 'contentContainerStyle'> & {
10
+ data: readonly T[];
11
+ renderItem: ListRenderItem<T>;
12
+ };
13
+
14
+ /**
15
+ * List for server-driven data: debounced search, sort + filter controls with an
16
+ * active-filter summary, pull-to-refresh, infinite scroll with a hasMore /
17
+ * loadingMore footer, and distinct loading / empty / no-results / error states.
18
+ * It renders exactly the rows it is given: filtering, sorting and paging are the
19
+ * server's job, reported through the callbacks.
20
+ */
21
+ export function DataList<T>(props: DataListProps<T>): ReactElement {
22
+ const { data, renderItem, keyExtractor, extraData, ItemSeparatorComponent, contentContainerStyle, ListHeaderComponent, testID, search, filters, onFilterChange, onClearFilters, sort } = props;
23
+ const theme = useTheme();
24
+ const { empty, footer, onEndReached, refreshControl } = useServerList(props, data.length);
25
+ const hasControls = !!(search || sort || filters?.length);
26
+ return (
27
+ <View style={{ flex: 1 }} testID={testID ? `${testID}-root` : undefined}>
28
+ {hasControls ? (
29
+ <View style={{ paddingHorizontal: theme.spacing.lg, paddingTop: theme.spacing.sm }}>
30
+ <ListControls search={search} filters={filters} onFilterChange={onFilterChange} onClearFilters={onClearFilters} sort={sort} testID={testID} />
31
+ </View>
32
+ ) : null}
33
+ <FlatList
34
+ testID={testID}
35
+ data={data}
36
+ renderItem={renderItem}
37
+ keyExtractor={keyExtractor}
38
+ extraData={extraData}
39
+ ItemSeparatorComponent={ItemSeparatorComponent}
40
+ ListHeaderComponent={ListHeaderComponent ? <>{ListHeaderComponent}</> : undefined}
41
+ ListEmptyComponent={empty ?? undefined}
42
+ ListFooterComponent={footer ?? undefined}
43
+ onEndReached={onEndReached}
44
+ onEndReachedThreshold={0.5}
45
+ refreshControl={refreshControl}
46
+ keyboardShouldPersistTaps="handled"
47
+ keyboardDismissMode="on-drag"
48
+ contentContainerStyle={[{ paddingHorizontal: theme.spacing.lg, paddingBottom: theme.spacing.xl, flexGrow: 1 }, contentContainerStyle]}
49
+ />
50
+ </View>
51
+ );
52
+ }
@@ -14,6 +14,9 @@ export interface EmptyStateProps {
14
14
  onAction?: () => void;
15
15
  /** Layout overrides (e.g. margins), merged after the base style. */
16
16
  style?: StyleProp<ViewStyle>;
17
+ testID?: string;
18
+ /** testID for the action button. */
19
+ actionTestID?: string;
17
20
  }
18
21
 
19
22
  /**
@@ -22,10 +25,11 @@ export interface EmptyStateProps {
22
25
  * leaving a screen blank (or showing a bare table header) when there's nothing
23
26
  * to display yet.
24
27
  */
25
- export function EmptyState({ icon, title, description, actionLabel, onAction, style }: EmptyStateProps) {
28
+ export function EmptyState({ icon, title, description, actionLabel, onAction, style, testID, actionTestID }: EmptyStateProps) {
26
29
  const theme = useTheme();
27
30
  return (
28
31
  <View
32
+ testID={testID}
29
33
  style={[
30
34
  {
31
35
  alignItems: 'center',
@@ -55,6 +59,7 @@ export function EmptyState({ icon, title, description, actionLabel, onAction, st
55
59
  title={actionLabel}
56
60
  variant="primary"
57
61
  onPress={onAction}
62
+ testID={actionTestID}
58
63
  style={{ marginTop: theme.spacing.sm, alignSelf: 'stretch' }}
59
64
  />
60
65
  ) : null}
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Text as RNText, View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { Label } from './Label';
4
+ import { useTheme } from './ThemeProvider';
5
+ import { errorTextColor } from '../styles-overlay';
6
+
7
+ export interface FormFieldProps {
8
+ label?: string;
9
+ required?: boolean;
10
+ hint?: string;
11
+ /** When set, replaces the hint and is announced as an alert. */
12
+ error?: string;
13
+ /** Right-aligned text under the control, e.g. a character counter. */
14
+ footerRight?: string;
15
+ children: ReactNode;
16
+ style?: StyleProp<ViewStyle>;
17
+ }
18
+
19
+ /** Label + control + hint/error wrapper for any input. */
20
+ export function FormField({ label, required, hint, error, footerRight, children, style }: FormFieldProps) {
21
+ const theme = useTheme();
22
+ return (
23
+ <View style={[{ gap: theme.spacing.xs }, style]}>
24
+ {label ? <Label>{label}{required ? <RNText style={{ color: theme.colors.destructive }}> *</RNText> : null}</Label> : null}
25
+ {children}
26
+ {error || hint || footerRight ? (
27
+ <View style={{ flexDirection: 'row', justifyContent: 'space-between', gap: theme.spacing.sm }}>
28
+ {error ? (
29
+ <RNText accessibilityRole="alert" style={{ flex: 1, color: errorTextColor(theme), fontSize: theme.fontSizes.xs }}>{error}</RNText>
30
+ ) : (
31
+ <RNText style={{ flex: 1, color: theme.colors.mutedForeground, fontSize: theme.fontSizes.xs }}>{hint ?? ''}</RNText>
32
+ )}
33
+ {footerRight ? <RNText style={{ color: theme.colors.mutedForeground, fontSize: theme.fontSizes.xs }}>{footerRight}</RNText> : null}
34
+ </View>
35
+ ) : null}
36
+ </View>
37
+ );
38
+ }
@@ -0,0 +1,46 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Pressable, Text as RNText, View } from 'react-native';
3
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
+ import { useTheme } from './ThemeProvider';
5
+ import { headerStyle } from '../styles-overlay';
6
+ import { textStyle } from '../styles';
7
+
8
+ export interface HeaderProps {
9
+ title: string;
10
+ subtitle?: string;
11
+ /** Shows a back button when set. */
12
+ onBack?: () => void;
13
+ backLabel?: string;
14
+ /** Node pinned right, e.g. an action Button. */
15
+ trailing?: ReactNode;
16
+ /** Pad for the status bar / notch. Default false (Screen already does). */
17
+ safeArea?: boolean;
18
+ testID?: string;
19
+ }
20
+
21
+ /** Stack-screen header: back button, title/subtitle, trailing actions. */
22
+ export function Header({ title, subtitle, onBack, backLabel = 'Back', trailing, safeArea = false, testID }: HeaderProps) {
23
+ const theme = useTheme();
24
+ const insets = useSafeAreaInsets();
25
+ return (
26
+ <View testID={testID} style={headerStyle(theme, safeArea ? insets.top : 0)}>
27
+ {onBack ? (
28
+ <Pressable
29
+ testID={testID ? `${testID}-back` : undefined}
30
+ accessibilityRole="button"
31
+ accessibilityLabel={backLabel}
32
+ hitSlop={8}
33
+ onPress={onBack}
34
+ style={{ width: 44, height: 44, marginLeft: -theme.spacing.sm, alignItems: 'center', justifyContent: 'center' }}
35
+ >
36
+ <RNText style={{ color: theme.colors.primary, fontSize: 32, lineHeight: 34 }}>‹</RNText>
37
+ </Pressable>
38
+ ) : null}
39
+ <View style={{ flex: 1 }}>
40
+ <RNText accessibilityRole="header" numberOfLines={1} style={[textStyle(theme, 'heading'), { fontSize: theme.fontSizes.lg }]}>{title}</RNText>
41
+ {subtitle ? <RNText numberOfLines={1} style={textStyle(theme, 'caption')}>{subtitle}</RNText> : null}
42
+ </View>
43
+ {trailing ? <View style={{ flexDirection: 'row', alignItems: 'center', gap: theme.spacing.sm }}>{trailing}</View> : null}
44
+ </View>
45
+ );
46
+ }
@@ -0,0 +1,85 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { Pressable, ScrollView, Text as RNText, View } from 'react-native';
3
+ import { Chip } from './Chip';
4
+ import { SearchBar } from './SearchBar';
5
+ import { SegmentedControl } from './SegmentedControl';
6
+ import { Select } from './Select';
7
+ import { useTheme } from './ThemeProvider';
8
+ import { createDebouncer, summarizeFilters } from '../logic/listState';
9
+ import type { ServerListProps } from './serverListTypes';
10
+
11
+ type Props = Pick<ServerListProps, 'search' | 'filters' | 'onFilterChange' | 'onClearFilters' | 'sort' | 'testID'>;
12
+
13
+ /** Search, sort, filter chips and the active-filter summary shared by the server lists. */
14
+ export function ListControls({ search, filters = [], onFilterChange, onClearFilters, sort, testID }: Props) {
15
+ const theme = useTheme();
16
+ const id = (s: string) => (testID ? `${testID}-${s}` : undefined);
17
+ const latest = useRef(search);
18
+ latest.current = search;
19
+ const debouncer = useRef(createDebouncer<string>((t) => latest.current?.onSearch(t), search?.debounceMs ?? 300)).current;
20
+ useEffect(() => () => debouncer.cancel(), [debouncer]);
21
+
22
+ const summary = summarizeFilters(filters, search?.value ?? '');
23
+
24
+ return (
25
+ <View style={{ gap: theme.spacing.sm, paddingBottom: theme.spacing.sm }}>
26
+ {search ? (
27
+ <SearchBar
28
+ testID={id('search')}
29
+ value={search.value}
30
+ placeholder={search.placeholder}
31
+ onChangeText={(t) => { search.onChange(t); debouncer.call(t); }}
32
+ onSubmit={(t) => { debouncer.cancel(); search.onSearch(t); }}
33
+ onClear={() => debouncer.flush()}
34
+ />
35
+ ) : null}
36
+ {sort ? <SegmentedControl testID={id('sort')} options={sort.options} value={sort.value} onChange={sort.onChange} /> : null}
37
+ {filters.length ? (
38
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="handled" contentContainerStyle={{ gap: theme.spacing.sm, alignItems: 'center' }}>
39
+ {filters.map((g) =>
40
+ g.display === 'select' ? (
41
+ <Select
42
+ key={g.key}
43
+ compact
44
+ testID={id(`filter-${g.key}`)}
45
+ label={g.label}
46
+ options={g.options}
47
+ value={g.value}
48
+ clearLabel={`All ${g.label.toLowerCase()}`}
49
+ onChange={(v) => onFilterChange?.(g.key, v)}
50
+ />
51
+ ) : (
52
+ g.options.map((o) => {
53
+ const active = g.value === o.value;
54
+ return (
55
+ <Chip
56
+ key={`${g.key}:${o.value}`}
57
+ testID={id(`filter-${g.key}-${o.value}`)}
58
+ label={o.label}
59
+ selected={active}
60
+ accessibilityLabel={`${g.label}: ${o.label}`}
61
+ onPress={() => onFilterChange?.(g.key, active ? null : o.value)}
62
+ />
63
+ );
64
+ })
65
+ ),
66
+ )}
67
+ </ScrollView>
68
+ ) : null}
69
+ {summary.count > 0 && onClearFilters ? (
70
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: theme.spacing.sm }}>
71
+ <RNText numberOfLines={2} style={{ flex: 1, color: theme.colors.mutedForeground, fontSize: theme.fontSizes.xs }}>{summary.labels.join(' · ')}</RNText>
72
+ <Pressable
73
+ testID={id('clear-filters')}
74
+ accessibilityRole="button"
75
+ accessibilityLabel={`Clear ${summary.count} filter${summary.count === 1 ? '' : 's'}`}
76
+ hitSlop={8}
77
+ onPress={onClearFilters}
78
+ >
79
+ <RNText style={{ color: theme.colors.primary, fontSize: theme.fontSizes.sm, fontWeight: '600' }}>Clear</RNText>
80
+ </Pressable>
81
+ </View>
82
+ ) : null}
83
+ </View>
84
+ );
85
+ }
@@ -0,0 +1,93 @@
1
+ import { type ReactNode } from 'react';
2
+ import { Linking, Platform, Text as RNText, View, type StyleProp, type TextStyle, type ViewStyle } from 'react-native';
3
+ import { useTheme } from './ThemeProvider';
4
+ import { textStyle } from '../styles';
5
+ import { parseMarkdown, type Block, type Inline } from '../logic/markdown';
6
+ import type { Theme } from '../theme';
7
+
8
+ export interface MarkdownProps {
9
+ /** Markdown source. */
10
+ children: string;
11
+ /** Called with the href of a pressed link. Defaults to Linking.openURL. */
12
+ onLinkPress?: (href: string) => void;
13
+ /** Overrides the body text colour (e.g. inside a coloured bubble). */
14
+ color?: string;
15
+ style?: StyleProp<ViewStyle>;
16
+ testID?: string;
17
+ }
18
+
19
+ const MONO = Platform.select({ ios: 'Menlo', default: 'monospace' });
20
+
21
+ function Inlines({ items, theme, onLink, color }: { items: Inline[]; theme: Theme; onLink: (h: string) => void; color?: string }): ReactNode {
22
+ return items.map((n, i) => {
23
+ switch (n.type) {
24
+ case 'text':
25
+ return n.text;
26
+ case 'bold':
27
+ return <RNText key={i} style={{ fontWeight: '700' }}>{Inlines({ items: n.children, theme, onLink, color })}</RNText>;
28
+ case 'italic':
29
+ return <RNText key={i} style={{ fontStyle: 'italic' }}>{Inlines({ items: n.children, theme, onLink, color })}</RNText>;
30
+ case 'code':
31
+ return <RNText key={i} style={{ fontFamily: MONO, backgroundColor: theme.colors.muted, fontSize: theme.fontSizes.sm }}>{n.text}</RNText>;
32
+ case 'link':
33
+ return (
34
+ <RNText key={i} accessibilityRole="link" onPress={() => onLink(n.href)} style={{ color: theme.colors.primary, textDecorationLine: 'underline' }}>
35
+ {Inlines({ items: n.children, theme, onLink, color })}
36
+ </RNText>
37
+ );
38
+ }
39
+ });
40
+ }
41
+
42
+ function BlockView({ block, theme, onLink, color }: { block: Block; theme: Theme; onLink: (h: string) => void; color?: string }) {
43
+ const body: TextStyle = { ...textStyle(theme, 'body'), lineHeight: Math.round(theme.fontSizes.base * 1.5), ...(color ? { color } : null) };
44
+ const inl = (items: Inline[]) => <Inlines items={items} theme={theme} onLink={onLink} color={color} />;
45
+ switch (block.type) {
46
+ case 'heading': {
47
+ const size = { 1: theme.fontSizes['2xl'], 2: theme.fontSizes.xl, 3: theme.fontSizes.lg }[block.level];
48
+ return <RNText accessibilityRole="header" style={{ ...body, fontSize: size, fontWeight: '700', lineHeight: Math.round(size * 1.25) }}>{inl(block.inlines)}</RNText>;
49
+ }
50
+ case 'paragraph':
51
+ return <RNText style={body}>{inl(block.inlines)}</RNText>;
52
+ case 'bullets':
53
+ case 'numbered':
54
+ return (
55
+ <View style={{ gap: theme.spacing.xs }}>
56
+ {block.items.map((item, i) => (
57
+ <View key={i} style={{ flexDirection: 'row', gap: theme.spacing.sm }}>
58
+ <RNText style={[body, { minWidth: 18 }]}>{block.type === 'bullets' ? '•' : `${i + 1}.`}</RNText>
59
+ <RNText style={[body, { flex: 1 }]}>{inl(item)}</RNText>
60
+ </View>
61
+ ))}
62
+ </View>
63
+ );
64
+ case 'quote':
65
+ return (
66
+ <View style={{ borderLeftWidth: 3, borderLeftColor: theme.colors.border, paddingLeft: theme.spacing.md }}>
67
+ <RNText style={[body, { color: color ?? theme.colors.mutedForeground }]}>{inl(block.inlines)}</RNText>
68
+ </View>
69
+ );
70
+ case 'code':
71
+ return (
72
+ <View style={{ backgroundColor: theme.colors.muted, borderRadius: theme.radii.md, padding: theme.spacing.md }}>
73
+ <RNText style={[body, { fontFamily: MONO, fontSize: theme.fontSizes.sm }]}>{block.text}</RNText>
74
+ </View>
75
+ );
76
+ case 'rule':
77
+ return <View style={{ height: 1, backgroundColor: theme.colors.border }} />;
78
+ }
79
+ }
80
+
81
+ /** Renders a markdown subset (headings, bold/italic/code, bullets, numbered,
82
+ * quotes, fenced code, safe links). For summaries, answers and transcripts. */
83
+ export function Markdown({ children, onLinkPress, color, style, testID }: MarkdownProps) {
84
+ const theme = useTheme();
85
+ const blocks = parseMarkdown(children);
86
+ const onLink = onLinkPress ?? ((href: string) => { void Linking.openURL(href); });
87
+ if (!blocks.length) return null;
88
+ return (
89
+ <View testID={testID} style={[{ gap: theme.spacing.md }, style]}>
90
+ {blocks.map((b, i) => <BlockView key={i} block={b} theme={theme} onLink={onLink} color={color} />)}
91
+ </View>
92
+ );
93
+ }
@@ -0,0 +1,49 @@
1
+ import { Pressable, Text as RNText, View, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { Markdown } from './Markdown';
3
+ import { Spinner } from './Spinner';
4
+ import { useTheme } from './ThemeProvider';
5
+ import { bubbleStyle, bubbleTextColor, type ChatRole } from '../styles-chat';
6
+
7
+ export interface MessageBubbleProps {
8
+ role: ChatRole;
9
+ text: string;
10
+ /** Assistant is still answering: shows a typing indicator. */
11
+ pending?: boolean;
12
+ /** Message failed to send; shows a retry row when onRetry is given. */
13
+ error?: boolean;
14
+ onRetry?: () => void;
15
+ style?: StyleProp<ViewStyle>;
16
+ testID?: string;
17
+ }
18
+
19
+ /** Chat message. User bubbles are right-aligned/primary; assistant text renders as markdown. */
20
+ export function MessageBubble({ role, text, pending, error, onRetry, style, testID }: MessageBubbleProps) {
21
+ const theme = useTheme();
22
+ const color = bubbleTextColor(theme, role);
23
+ return (
24
+ <View
25
+ testID={testID}
26
+ accessibilityLabel={pending ? 'Assistant is typing' : undefined}
27
+ style={[bubbleStyle(theme, role), style]}
28
+ >
29
+ {pending ? (
30
+ <Spinner size="small" color={theme.colors.mutedForeground} />
31
+ ) : role === 'assistant' ? (
32
+ <Markdown color={color}>{text}</Markdown>
33
+ ) : (
34
+ <RNText selectable style={{ color, fontSize: theme.fontSizes.base, lineHeight: Math.round(theme.fontSizes.base * 1.4) }}>{text}</RNText>
35
+ )}
36
+ {error ? (
37
+ <Pressable
38
+ testID={testID ? `${testID}-retry` : undefined}
39
+ accessibilityRole="button"
40
+ accessibilityLabel="Retry sending"
41
+ onPress={onRetry}
42
+ style={{ marginTop: theme.spacing.xs }}
43
+ >
44
+ <RNText style={{ color: theme.colors.destructiveForeground, fontSize: theme.fontSizes.xs, fontWeight: '600' }}>Not sent. Tap to retry</RNText>
45
+ </Pressable>
46
+ ) : null}
47
+ </View>
48
+ );
49
+ }
@@ -0,0 +1,52 @@
1
+ import { Pressable, Text as RNText, View, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { Input } from './Input';
3
+ import { useTheme } from './ThemeProvider';
4
+
5
+ export interface SearchBarProps {
6
+ value: string;
7
+ onChangeText: (text: string) => void;
8
+ /** Keyboard "search" key. Receives the current value. */
9
+ onSubmit?: (text: string) => void;
10
+ /** Called after the clear button empties the field. */
11
+ onClear?: () => void;
12
+ placeholder?: string;
13
+ autoFocus?: boolean;
14
+ style?: StyleProp<ViewStyle>;
15
+ testID?: string;
16
+ }
17
+
18
+ /** Search field with a leading glyph and a clear button. Controlled; debouncing is the caller's job (see DataList). */
19
+ export function SearchBar({ value, onChangeText, onSubmit, onClear, placeholder = 'Search', autoFocus, style, testID }: SearchBarProps) {
20
+ const theme = useTheme();
21
+ return (
22
+ <View style={[{ justifyContent: 'center' }, style]}>
23
+ <Input
24
+ testID={testID}
25
+ value={value}
26
+ onChangeText={onChangeText}
27
+ onSubmitEditing={() => onSubmit?.(value)}
28
+ placeholder={placeholder}
29
+ accessibilityLabel={placeholder}
30
+ returnKeyType="search"
31
+ autoCapitalize="none"
32
+ autoCorrect={false}
33
+ autoFocus={autoFocus}
34
+ clearButtonMode="never"
35
+ style={{ paddingLeft: theme.spacing.md + 22, paddingRight: value ? 44 : theme.spacing.md, borderRadius: theme.radii.full }}
36
+ />
37
+ <RNText pointerEvents="none" accessibilityElementsHidden importantForAccessibility="no" style={{ position: 'absolute', left: theme.spacing.md, color: theme.colors.mutedForeground, fontSize: theme.fontSizes.sm }}>🔍</RNText>
38
+ {value ? (
39
+ <Pressable
40
+ testID={testID ? `${testID}-clear` : undefined}
41
+ accessibilityRole="button"
42
+ accessibilityLabel="Clear search"
43
+ hitSlop={8}
44
+ onPress={() => { onChangeText(''); onClear?.(); }}
45
+ style={{ position: 'absolute', right: 0, width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }}
46
+ >
47
+ <RNText style={{ color: theme.colors.mutedForeground, fontSize: theme.fontSizes.lg }}>✕</RNText>
48
+ </Pressable>
49
+ ) : null}
50
+ </View>
51
+ );
52
+ }