@startsimpli/ui-native 0.1.1 → 0.1.3

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 (42) 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/__tests__/tabbar.test.tsx +21 -0
  11. package/src/components/ChatComposer.tsx +65 -0
  12. package/src/components/Chip.tsx +5 -1
  13. package/src/components/DataList.tsx +52 -0
  14. package/src/components/EmptyState.tsx +6 -1
  15. package/src/components/FormField.tsx +38 -0
  16. package/src/components/Header.tsx +46 -0
  17. package/src/components/ListControls.tsx +85 -0
  18. package/src/components/Markdown.tsx +93 -0
  19. package/src/components/MessageBubble.tsx +49 -0
  20. package/src/components/SearchBar.tsx +52 -0
  21. package/src/components/SectionedList.tsx +55 -0
  22. package/src/components/SegmentedControl.tsx +4 -1
  23. package/src/components/Select.tsx +95 -0
  24. package/src/components/Sheet.tsx +41 -0
  25. package/src/components/StatusChip.tsx +27 -0
  26. package/src/components/SwipeRow.tsx +85 -0
  27. package/src/components/TabBar.tsx +1 -1
  28. package/src/components/TextArea.tsx +38 -0
  29. package/src/components/Toast.tsx +61 -0
  30. package/src/components/serverListTypes.ts +53 -0
  31. package/src/components/useServerList.tsx +58 -0
  32. package/src/index.ts +20 -0
  33. package/src/logic/chat.ts +3 -0
  34. package/src/logic/listState.ts +77 -0
  35. package/src/logic/markdown.ts +112 -0
  36. package/src/logic/select.ts +12 -0
  37. package/src/logic/status.ts +27 -0
  38. package/src/logic/swipe.ts +13 -0
  39. package/src/logic/toast.ts +28 -0
  40. package/src/styles-chat.ts +51 -0
  41. package/src/styles-overlay.ts +100 -0
  42. package/src/styles.ts +3 -0
@@ -0,0 +1,55 @@
1
+ import type { ReactElement } from 'react';
2
+ import { SectionList, Text as RNText, View, type SectionListProps } 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 SectionedListProps<T, S = { title: string }> = ServerListProps &
9
+ Pick<SectionListProps<T, S>, 'keyExtractor' | 'renderItem' | 'extraData' | 'contentContainerStyle'> & {
10
+ sections: ReadonlyArray<S & { data: readonly T[] }>;
11
+ /** Custom header; defaults to a bold section title. */
12
+ renderSectionHeader?: (info: { section: S & { data: readonly T[] } }) => ReactElement | null;
13
+ };
14
+
15
+ /** Grouped variant of DataList (e.g. to-dos by day). Same server-driven contract: it never regroups or filters rows. */
16
+ export function SectionedList<T, S extends { title: string } = { title: string }>(props: SectionedListProps<T, S>): ReactElement {
17
+ const { sections, renderItem, keyExtractor, extraData, contentContainerStyle, renderSectionHeader, ListHeaderComponent, testID, search, filters, onFilterChange, onClearFilters, sort } = props;
18
+ const theme = useTheme();
19
+ const count = sections.reduce((n, s) => n + s.data.length, 0);
20
+ const { empty, footer, onEndReached, refreshControl } = useServerList(props, count);
21
+ const hasControls = !!(search || sort || filters?.length);
22
+ return (
23
+ <View style={{ flex: 1 }} testID={testID ? `${testID}-root` : undefined}>
24
+ {hasControls ? (
25
+ <View style={{ paddingHorizontal: theme.spacing.lg, paddingTop: theme.spacing.sm }}>
26
+ <ListControls search={search} filters={filters} onFilterChange={onFilterChange} onClearFilters={onClearFilters} sort={sort} testID={testID} />
27
+ </View>
28
+ ) : null}
29
+ <SectionList
30
+ testID={testID}
31
+ sections={sections as any}
32
+ renderItem={renderItem as any}
33
+ keyExtractor={keyExtractor}
34
+ extraData={extraData}
35
+ stickySectionHeadersEnabled={false}
36
+ renderSectionHeader={({ section }) =>
37
+ renderSectionHeader ? renderSectionHeader({ section: section as any }) : (
38
+ <RNText accessibilityRole="header" style={{ paddingTop: theme.spacing.lg, paddingBottom: theme.spacing.xs, color: theme.colors.mutedForeground, fontSize: theme.fontSizes.sm, fontWeight: '600', textTransform: 'uppercase', backgroundColor: theme.colors.background }}>
39
+ {(section as any).title}
40
+ </RNText>
41
+ )
42
+ }
43
+ ListHeaderComponent={ListHeaderComponent ? <>{ListHeaderComponent}</> : undefined}
44
+ ListEmptyComponent={empty ?? undefined}
45
+ ListFooterComponent={footer ?? undefined}
46
+ onEndReached={onEndReached}
47
+ onEndReachedThreshold={0.5}
48
+ refreshControl={refreshControl}
49
+ keyboardShouldPersistTaps="handled"
50
+ keyboardDismissMode="on-drag"
51
+ contentContainerStyle={[{ paddingHorizontal: theme.spacing.lg, paddingBottom: theme.spacing.xl, flexGrow: 1 }, contentContainerStyle]}
52
+ />
53
+ </View>
54
+ );
55
+ }
@@ -11,6 +11,8 @@ export interface SegmentedControlProps<T extends string | number> {
11
11
  value: T;
12
12
  onChange: (value: T) => void;
13
13
  style?: StyleProp<ViewStyle>;
14
+ /** Each option gets testID `${testID}-${value}`. */
15
+ testID?: string;
14
16
  }
15
17
 
16
18
  /** Mutually-exclusive option selector rendered as a row of Chips. Generic over
@@ -20,11 +22,12 @@ export function SegmentedControl<T extends string | number>({
20
22
  value,
21
23
  onChange,
22
24
  style,
25
+ testID,
23
26
  }: SegmentedControlProps<T>) {
24
27
  return (
25
28
  <View style={[{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, style]}>
26
29
  {options.map((o) => (
27
- <Chip key={String(o.value)} label={o.label} selected={o.value === value} onPress={() => onChange(o.value)} />
30
+ <Chip key={String(o.value)} label={o.label} selected={o.value === value} testID={testID ? `${testID}-${o.value}` : undefined} onPress={() => onChange(o.value)} />
28
31
  ))}
29
32
  </View>
30
33
  );
@@ -0,0 +1,95 @@
1
+ import { useState } from 'react';
2
+ import { Pressable, Text as RNText, View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { Sheet } from './Sheet';
4
+ import { SearchBar } from './SearchBar';
5
+ import { FormField } from './FormField';
6
+ import { useTheme } from './ThemeProvider';
7
+ import { filterOptions, selectedLabel, type SelectOption } from '../logic/select';
8
+ import { inputStyle } from '../styles';
9
+
10
+ export type { SelectOption } from '../logic/select';
11
+
12
+ export interface SelectProps {
13
+ options: SelectOption[];
14
+ value: string | null;
15
+ /** Receives the chosen value, or null when the clear row is picked. */
16
+ onChange: (value: string | null) => void;
17
+ label?: string;
18
+ placeholder?: string;
19
+ /** Sheet title. Defaults to the label. */
20
+ title?: string;
21
+ /** Adds an "all / none" row (e.g. "All projects") that emits null. */
22
+ clearLabel?: string;
23
+ /** Shows a search box in the sheet. */
24
+ searchable?: boolean;
25
+ error?: string;
26
+ disabled?: boolean;
27
+ /** Pill-sized trigger for filter bars. */
28
+ compact?: boolean;
29
+ style?: StyleProp<ViewStyle>;
30
+ testID?: string;
31
+ }
32
+
33
+ /** Picker: a field-style trigger that opens a bottom Sheet of options. */
34
+ export function Select({ options, value, onChange, label, placeholder = 'Select', title, clearLabel, searchable, error, disabled, compact, style, testID }: SelectProps) {
35
+ const theme = useTheme();
36
+ const [open, setOpen] = useState(false);
37
+ const [query, setQuery] = useState('');
38
+ const current = selectedLabel(options, value);
39
+ const id = (s: string) => (testID ? `${testID}-${s}` : undefined);
40
+ const close = () => { setOpen(false); setQuery(''); };
41
+ const pick = (v: string | null) => { onChange(v); close(); };
42
+
43
+ const row = (key: string, text: string, selected: boolean, onPress: () => void, tid?: string, description?: string) => (
44
+ <Pressable
45
+ key={key}
46
+ testID={tid}
47
+ accessibilityRole="button"
48
+ accessibilityState={{ selected }}
49
+ onPress={onPress}
50
+ style={({ pressed }) => ({ minHeight: 48, flexDirection: 'row', alignItems: 'center', gap: theme.spacing.md, borderBottomWidth: 1, borderBottomColor: theme.colors.border, opacity: pressed ? 0.6 : 1 })}
51
+ >
52
+ <View style={{ flex: 1 }}>
53
+ <RNText style={{ color: theme.colors.foreground, fontSize: theme.fontSizes.base, fontWeight: selected ? '600' : '400' }}>{text}</RNText>
54
+ {description ? <RNText style={{ color: theme.colors.mutedForeground, fontSize: theme.fontSizes.sm }}>{description}</RNText> : null}
55
+ </View>
56
+ {selected ? <RNText style={{ color: theme.colors.primary, fontSize: theme.fontSizes.lg }}>✓</RNText> : null}
57
+ </Pressable>
58
+ );
59
+
60
+ const shown = filterOptions(options, query);
61
+ const trigger = (
62
+ <Pressable
63
+ testID={testID}
64
+ accessibilityRole="button"
65
+ accessibilityLabel={`${label ?? placeholder}: ${current ?? 'none selected'}`}
66
+ accessibilityState={{ disabled: !!disabled, expanded: open }}
67
+ disabled={disabled}
68
+ onPress={() => setOpen(true)}
69
+ style={({ pressed }) => [
70
+ compact
71
+ ? { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: theme.spacing.md, paddingVertical: 6, borderRadius: theme.radii.full, borderWidth: 1, borderColor: value != null ? theme.colors.primary : theme.colors.border, backgroundColor: value != null ? theme.colors.primary : 'transparent' }
72
+ : { ...(inputStyle(theme, { invalid: !!error }) as ViewStyle), flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
73
+ disabled ? { opacity: 0.5 } : null,
74
+ pressed ? { opacity: 0.7 } : null,
75
+ ]}
76
+ >
77
+ <RNText numberOfLines={1} style={{ flexShrink: 1, fontSize: compact ? theme.fontSizes.sm : theme.fontSizes.base, color: compact ? (value != null ? theme.colors.primaryForeground : theme.colors.mutedForeground) : current ? theme.colors.foreground : theme.colors.mutedForeground }}>
78
+ {current ?? (compact && label ? label : placeholder)}
79
+ </RNText>
80
+ <RNText style={{ color: compact && value != null ? theme.colors.primaryForeground : theme.colors.mutedForeground }}>▾</RNText>
81
+ </Pressable>
82
+ );
83
+
84
+ return (
85
+ <>
86
+ {compact ? <View style={style}>{trigger}</View> : <FormField label={label} error={error} style={style}>{trigger}</FormField>}
87
+ <Sheet visible={open} onClose={close} title={title ?? label ?? placeholder} testID={id('sheet')}>
88
+ {searchable ? <SearchBar testID={id('search-input')} value={query} onChangeText={setQuery} style={{ marginBottom: theme.spacing.sm }} /> : null}
89
+ {clearLabel ? row('__clear', clearLabel, value == null, () => pick(null), id('clear')) : null}
90
+ {shown.map((o) => row(o.value, o.label, o.value === value, () => pick(o.value), id(`option-${o.value}`), o.description))}
91
+ {!shown.length ? <RNText style={{ color: theme.colors.mutedForeground, paddingVertical: theme.spacing.lg }}>No matches</RNText> : null}
92
+ </Sheet>
93
+ </>
94
+ );
95
+ }
@@ -0,0 +1,41 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Modal, Pressable, ScrollView, Text as RNText, View, useWindowDimensions } from 'react-native';
3
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
+ import { useTheme } from './ThemeProvider';
5
+ import { sheetBackdropStyle, sheetGrabberStyle, sheetPanelStyle } from '../styles-overlay';
6
+ import { textStyle } from '../styles';
7
+
8
+ export interface SheetProps {
9
+ visible: boolean;
10
+ onClose: () => void;
11
+ title?: string;
12
+ children?: ReactNode;
13
+ /** Fraction of the window height the sheet may fill. Default 0.85. */
14
+ maxHeightRatio?: number;
15
+ testID?: string;
16
+ }
17
+
18
+ /** Bottom sheet over a dimmed backdrop. Tap the backdrop (or swipe back / hardware back) to dismiss. */
19
+ export function Sheet({ visible, onClose, title, children, maxHeightRatio = 0.85, testID }: SheetProps) {
20
+ const theme = useTheme();
21
+ const insets = useSafeAreaInsets();
22
+ const { height } = useWindowDimensions();
23
+ return (
24
+ <Modal visible={visible} transparent animationType="slide" onRequestClose={onClose} testID={testID}>
25
+ <View style={{ flex: 1, justifyContent: 'flex-end' }}>
26
+ <Pressable
27
+ testID={testID ? `${testID}-backdrop` : undefined}
28
+ accessibilityRole="button"
29
+ accessibilityLabel="Dismiss"
30
+ onPress={onClose}
31
+ style={sheetBackdropStyle()}
32
+ />
33
+ <View accessibilityViewIsModal style={sheetPanelStyle(theme, height * maxHeightRatio, insets.bottom)}>
34
+ <View style={sheetGrabberStyle(theme)} />
35
+ {title ? <RNText accessibilityRole="header" style={[textStyle(theme, 'heading'), { marginBottom: theme.spacing.md }]}>{title}</RNText> : null}
36
+ <ScrollView keyboardShouldPersistTaps="handled">{children}</ScrollView>
37
+ </View>
38
+ </View>
39
+ </Modal>
40
+ );
41
+ }
@@ -0,0 +1,27 @@
1
+ import { Text as RNText, View, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { useTheme } from './ThemeProvider';
3
+ import { badgeStyle, badgeTextStyle } from '../styles';
4
+ import { formatProgress, statusTone } from '../logic/status';
5
+
6
+ export interface StatusChipProps {
7
+ /** Domain status, e.g. queued / processing / done / failed. */
8
+ status: string;
9
+ /** 0..1. Appends a percent to the label. */
10
+ progress?: number;
11
+ /** Overrides the derived label. */
12
+ label?: string;
13
+ style?: StyleProp<ViewStyle>;
14
+ testID?: string;
15
+ }
16
+
17
+ /** Badge that maps a domain status (and optional progress) to a themed variant. */
18
+ export function StatusChip({ status, progress, label, style, testID }: StatusChipProps) {
19
+ const theme = useTheme();
20
+ const tone = statusTone(status);
21
+ const text = `${label ?? tone.label}${progress != null ? ` ${formatProgress(progress)}` : ''}`;
22
+ return (
23
+ <View testID={testID} accessible accessibilityLabel={text} style={[badgeStyle(theme, tone.variant), style]}>
24
+ <RNText style={badgeTextStyle(theme, tone.variant)}>{text}</RNText>
25
+ </View>
26
+ );
27
+ }
@@ -0,0 +1,85 @@
1
+ import { useMemo, useRef, type ReactNode } from 'react';
2
+ import { Animated, PanResponder, Pressable, Text as RNText, View } from 'react-native';
3
+ import { useTheme } from './ThemeProvider';
4
+ import { swipeActionStyle, swipeActionTextStyle, type SwipeTone } from '../styles-overlay';
5
+ import { clampSwipe, settleSwipe } from '../logic/swipe';
6
+
7
+ export interface SwipeAction {
8
+ key: string;
9
+ label: string;
10
+ onPress: () => void;
11
+ tone?: SwipeTone;
12
+ }
13
+
14
+ export interface SwipeRowProps {
15
+ /** Trailing actions revealed by swiping left (and exposed to VoiceOver as custom actions). */
16
+ actions: SwipeAction[];
17
+ children: ReactNode;
18
+ /** Width of each action button. Default 84. */
19
+ actionWidth?: number;
20
+ /** Start with the actions revealed. */
21
+ defaultOpen?: boolean;
22
+ testID?: string;
23
+ }
24
+
25
+ /** Row that reveals trailing actions (done / delete ...) on a left swipe. Core RN only. */
26
+ export function SwipeRow({ actions, children, actionWidth = 84, defaultOpen = false, testID }: SwipeRowProps) {
27
+ const theme = useTheme();
28
+ const total = actionWidth * actions.length;
29
+ const x = useRef(new Animated.Value(defaultOpen ? -total : 0)).current;
30
+ const offset = useRef(defaultOpen ? -total : 0);
31
+ const start = useRef(0);
32
+
33
+ const settle = (to: number) => {
34
+ offset.current = to;
35
+ Animated.spring(x, { toValue: to, useNativeDriver: true, bounciness: 0 }).start();
36
+ };
37
+
38
+ const pan = useMemo(
39
+ () =>
40
+ PanResponder.create({
41
+ // Horizontal intent only, so vertical list scrolling wins otherwise.
42
+ onMoveShouldSetPanResponder: (_e, g) => Math.abs(g.dx) > 10 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5,
43
+ onPanResponderGrant: () => { start.current = offset.current; },
44
+ onPanResponderMove: (_e, g) => x.setValue(clampSwipe(start.current + g.dx, total)),
45
+ onPanResponderRelease: (_e, g) => {
46
+ const at = clampSwipe(start.current + g.dx, total);
47
+ settle(settleSwipe(at, g.vx, total) === 'open' ? -total : 0);
48
+ },
49
+ onPanResponderTerminate: () => settle(offset.current),
50
+ }),
51
+ [total], // eslint-disable-line react-hooks/exhaustive-deps
52
+ );
53
+
54
+ const run = (a: SwipeAction) => { settle(0); a.onPress(); };
55
+
56
+ return (
57
+ <View
58
+ testID={testID}
59
+ accessibilityActions={actions.map((a) => ({ name: a.key, label: a.label }))}
60
+ onAccessibilityAction={(e) => {
61
+ const a = actions.find((x2) => x2.key === e.nativeEvent.actionName);
62
+ if (a) a.onPress();
63
+ }}
64
+ style={{ overflow: 'hidden' }}
65
+ >
66
+ <View style={{ position: 'absolute', top: 0, right: 0, bottom: 0, flexDirection: 'row' }}>
67
+ {actions.map((a) => (
68
+ <Pressable
69
+ key={a.key}
70
+ testID={testID ? `${testID}-action-${a.key}` : undefined}
71
+ accessibilityRole="button"
72
+ accessibilityLabel={a.label}
73
+ onPress={() => run(a)}
74
+ style={swipeActionStyle(theme, a.tone ?? 'neutral', actionWidth)}
75
+ >
76
+ <RNText style={swipeActionTextStyle(theme, a.tone ?? 'neutral')}>{a.label}</RNText>
77
+ </Pressable>
78
+ ))}
79
+ </View>
80
+ <Animated.View {...pan.panHandlers} style={{ transform: [{ translateX: x }], backgroundColor: theme.colors.background }}>
81
+ {children}
82
+ </Animated.View>
83
+ </View>
84
+ );
85
+ }
@@ -41,7 +41,7 @@ export function TabBar({ items, activeKey, onChange, style }: TabBarProps) {
41
41
  style={tabItemStyle(theme)}
42
42
  >
43
43
  {item.icon?.({ active, color })}
44
- <RNText style={tabLabelStyle(theme, active)}>{item.label}</RNText>
44
+ <RNText numberOfLines={1} ellipsizeMode="tail" style={tabLabelStyle(theme, active)}>{item.label}</RNText>
45
45
  </Pressable>
46
46
  );
47
47
  })}
@@ -0,0 +1,38 @@
1
+ import type { StyleProp, ViewStyle } from 'react-native';
2
+ import { Input, type InputProps } from './Input';
3
+ import { FormField } from './FormField';
4
+
5
+ export interface TextAreaProps extends Omit<InputProps, 'multiline' | 'invalid'> {
6
+ label?: string;
7
+ hint?: string;
8
+ error?: string;
9
+ required?: boolean;
10
+ /** Visible lines at rest. Default 4. */
11
+ rows?: number;
12
+ containerStyle?: StyleProp<ViewStyle>;
13
+ }
14
+
15
+ /** Multiline Input wrapped in a FormField (label, hint, error, optional char counter with maxLength). */
16
+ export function TextArea({ label, hint, error, required, rows = 4, containerStyle, style, maxLength, value, accessibilityLabel, ...rest }: TextAreaProps) {
17
+ return (
18
+ <FormField
19
+ label={label}
20
+ hint={hint}
21
+ error={error}
22
+ required={required}
23
+ style={containerStyle}
24
+ footerRight={maxLength ? `${(value ?? '').length}/${maxLength}` : undefined}
25
+ >
26
+ <Input
27
+ multiline
28
+ textAlignVertical="top"
29
+ value={value}
30
+ maxLength={maxLength}
31
+ invalid={!!error}
32
+ accessibilityLabel={accessibilityLabel ?? label}
33
+ style={[{ minHeight: rows * 22 + 16 }, style]}
34
+ {...rest}
35
+ />
36
+ </FormField>
37
+ );
38
+ }
@@ -0,0 +1,61 @@
1
+ import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore, 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 { toastStyle, toastTextStyle } from '../styles-overlay';
6
+ import { createToastStore, type ToastItem, type ToastOptions } from '../logic/toast';
7
+
8
+ export type { ToastOptions, ToastTone } from '../logic/toast';
9
+
10
+ export interface ToastApi {
11
+ show: (message: string, options?: ToastOptions) => string;
12
+ dismiss: (id: string) => void;
13
+ clear: () => void;
14
+ }
15
+
16
+ const ToastContext = createContext<ToastApi | null>(null);
17
+
18
+ function ToastView({ toast, onDismiss }: { toast: ToastItem; onDismiss: (id: string) => void }) {
19
+ const theme = useTheme();
20
+ useEffect(() => {
21
+ if (toast.durationMs <= 0) return;
22
+ const t = setTimeout(() => onDismiss(toast.id), toast.durationMs);
23
+ return () => clearTimeout(t);
24
+ }, [toast.id, toast.durationMs, onDismiss]);
25
+ return (
26
+ <Pressable
27
+ testID={`toast-${toast.tone}`}
28
+ accessibilityRole="alert"
29
+ accessibilityLiveRegion="polite"
30
+ accessibilityLabel={toast.message}
31
+ accessibilityHint="Tap to dismiss"
32
+ onPress={() => onDismiss(toast.id)}
33
+ style={toastStyle(theme, toast.tone)}
34
+ >
35
+ <RNText style={toastTextStyle(theme, toast.tone)}>{toast.message}</RNText>
36
+ </Pressable>
37
+ );
38
+ }
39
+
40
+ /** Mount once near the root (inside ThemeProvider). Renders toasts at the top of the screen. */
41
+ export function ToastProvider({ children }: { children: ReactNode }) {
42
+ const theme = useTheme();
43
+ const insets = useSafeAreaInsets();
44
+ const store = useRef(createToastStore()).current;
45
+ const toasts = useSyncExternalStore(store.subscribe, store.getToasts, store.getToasts);
46
+ const api = useMemo<ToastApi>(() => ({ show: store.show, dismiss: store.dismiss, clear: store.clear }), [store]);
47
+ return (
48
+ <ToastContext.Provider value={api}>
49
+ {children}
50
+ <View pointerEvents="box-none" style={{ position: 'absolute', top: insets.top + theme.spacing.sm, left: theme.spacing.lg, right: theme.spacing.lg, gap: theme.spacing.sm }}>
51
+ {toasts.map((t) => <ToastView key={t.id} toast={t} onDismiss={store.dismiss} />)}
52
+ </View>
53
+ </ToastContext.Provider>
54
+ );
55
+ }
56
+
57
+ export function useToast(): ToastApi {
58
+ const ctx = useContext(ToastContext);
59
+ if (!ctx) throw new Error('useToast must be used inside a <ToastProvider>');
60
+ return ctx;
61
+ }
@@ -0,0 +1,53 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { FilterGroup } from '../logic/listState';
3
+
4
+ export type { FilterGroup, FilterOption } from '../logic/listState';
5
+
6
+ export interface ListSearch {
7
+ /** Controlled text shown in the field. */
8
+ value: string;
9
+ /** Fires on every keystroke (update your input state here). */
10
+ onChange: (text: string) => void;
11
+ /** Fires debounced, and immediately on submit / clear. Run the server query here. */
12
+ onSearch: (text: string) => void;
13
+ debounceMs?: number;
14
+ placeholder?: string;
15
+ }
16
+
17
+ export interface ListSort {
18
+ value: string;
19
+ options: Array<{ value: string; label: string }>;
20
+ onChange: (value: string) => void;
21
+ }
22
+
23
+ /**
24
+ * Props shared by DataList and SectionedList. Everything is server-driven: the
25
+ * list never filters, sorts or slices rows itself, it only reports what the user
26
+ * asked for and renders what it is given.
27
+ */
28
+ export interface ServerListProps {
29
+ loading?: boolean;
30
+ refreshing?: boolean;
31
+ onRefresh?: () => void;
32
+ /** Error message. With no rows it is the full-screen error; with rows it is the paging footer error. */
33
+ error?: string | null;
34
+ onRetry?: () => void;
35
+ hasMore?: boolean;
36
+ loadingMore?: boolean;
37
+ /** Called at most once per loaded length when the end is reached. */
38
+ onLoadMore?: () => void;
39
+ search?: ListSearch;
40
+ filters?: FilterGroup[];
41
+ /** value null clears that group. */
42
+ onFilterChange?: (key: string, value: string | null) => void;
43
+ /** Clears search text and every filter. Shown in the summary and the no-results state. */
44
+ onClearFilters?: () => void;
45
+ sort?: ListSort;
46
+ emptyTitle?: string;
47
+ emptyDescription?: string;
48
+ noResultsTitle?: string;
49
+ noResultsDescription?: string;
50
+ /** Rendered above the rows inside the scroll area. */
51
+ ListHeaderComponent?: ReactNode;
52
+ testID?: string;
53
+ }
@@ -0,0 +1,58 @@
1
+ import { useEffect, useRef, type ReactElement } from 'react';
2
+ import { RefreshControl, Text as RNText, View } from 'react-native';
3
+ import { Button } from './Button';
4
+ import { EmptyState } from './EmptyState';
5
+ import { SkeletonList } from './SkeletonList';
6
+ import { Spinner } from './Spinner';
7
+ import { useTheme } from './ThemeProvider';
8
+ import { errorTextColor } from '../styles-overlay';
9
+ import { createLoadMoreGuard, resolveListState, summarizeFilters } from '../logic/listState';
10
+ import type { ServerListProps } from './serverListTypes';
11
+
12
+ /** Behaviour shared by DataList and SectionedList: state views, the paging guard, footer and refresh control. */
13
+ export function useServerList(p: ServerListProps, itemCount: number) {
14
+ const theme = useTheme();
15
+ const guard = useRef(createLoadMoreGuard()).current;
16
+ const id = (s: string) => (p.testID ? `${p.testID}-${s}` : undefined);
17
+ const summary = summarizeFilters(p.filters ?? [], p.search?.value ?? '');
18
+ const signature = `${summary.labels.join('|')}#${p.sort?.value ?? ''}`;
19
+
20
+ // A new query, refresh or initial load starts a new paging sequence.
21
+ useEffect(() => { guard.reset(); }, [guard, signature, p.loading, p.refreshing]);
22
+
23
+ const state = resolveListState({ itemCount, loading: p.loading, refreshing: p.refreshing, error: p.error, hasActiveQuery: summary.count > 0 });
24
+ const retry = () => { guard.reset(); p.onRetry?.(); };
25
+
26
+ let empty: ReactElement | null = null;
27
+ if (state === 'loading') {
28
+ empty = <View testID={id('loading')} style={{ paddingTop: theme.spacing.sm }}><SkeletonList count={4} height={64} /></View>;
29
+ } else if (state === 'error') {
30
+ empty = <EmptyState testID={id('error')} icon="⚠️" title="Something went wrong" description={p.error ?? undefined} actionLabel={p.onRetry ? 'Retry' : undefined} onAction={retry} actionTestID={id('retry')} />;
31
+ } else if (state === 'noResults') {
32
+ empty = <EmptyState testID={id('no-results')} icon="🔍" title={p.noResultsTitle ?? 'No results'} description={p.noResultsDescription ?? 'Try a different search or clear the filters.'} actionLabel={p.onClearFilters ? 'Clear filters' : undefined} onAction={p.onClearFilters} actionTestID={id('empty-clear')} />;
33
+ } else if (state === 'empty') {
34
+ empty = <EmptyState testID={id('empty')} title={p.emptyTitle ?? 'Nothing here yet'} description={p.emptyDescription} />;
35
+ }
36
+
37
+ let footer: ReactElement | null = null;
38
+ if (state === 'content') {
39
+ if (p.error) {
40
+ footer = (
41
+ <View testID={id('footer-error')} style={{ alignItems: 'center', gap: theme.spacing.sm, paddingVertical: theme.spacing.lg }}>
42
+ <RNText accessibilityRole="alert" style={{ color: errorTextColor(theme), fontSize: theme.fontSizes.sm }}>{p.error}</RNText>
43
+ {p.onRetry ? <Button testID={id('footer-retry')} title="Retry" variant="outline" size="sm" onPress={retry} /> : null}
44
+ </View>
45
+ );
46
+ } else if (p.loadingMore) {
47
+ footer = <View testID={id('footer-loading')} style={{ paddingVertical: theme.spacing.lg }}><Spinner /></View>;
48
+ }
49
+ }
50
+
51
+ const onEndReached = () => {
52
+ if (guard.tryStart({ hasMore: !!p.hasMore, loading: !!p.loading, loadingMore: !!p.loadingMore, error: !!p.error }, itemCount)) p.onLoadMore?.();
53
+ };
54
+
55
+ const refreshControl = p.onRefresh ? <RefreshControl refreshing={!!p.refreshing} onRefresh={p.onRefresh} tintColor={theme.colors.mutedForeground} /> : undefined;
56
+
57
+ return { empty, footer, onEndReached, refreshControl, state };
58
+ }
package/src/index.ts CHANGED
@@ -62,3 +62,23 @@ export { GradientBox, type GradientBoxProps } from './components/GradientBox';
62
62
  export { HeroBanner, type HeroBannerProps } from './components/HeroBanner';
63
63
  export { AccentProvider, useAccent } from './components/AccentContext';
64
64
  export { DataTable, type DataTableProps, type DataColumn, type DataRow } from './components/DataTable';
65
+
66
+ // Tandem-screen primitives: rich text, overlays, forms, server-driven lists, chat
67
+ export { Markdown, type MarkdownProps } from './components/Markdown';
68
+ export { parseMarkdown, parseInline, type Block as MarkdownBlock, type Inline as MarkdownInline } from './logic/markdown';
69
+ export { Sheet, type SheetProps } from './components/Sheet';
70
+ export { Select, type SelectProps, type SelectOption } from './components/Select';
71
+ export { FormField, type FormFieldProps } from './components/FormField';
72
+ export { TextArea, type TextAreaProps } from './components/TextArea';
73
+ export { SearchBar, type SearchBarProps } from './components/SearchBar';
74
+ export { Header, type HeaderProps } from './components/Header';
75
+ export { StatusChip, type StatusChipProps } from './components/StatusChip';
76
+ export { statusTone, formatProgress } from './logic/status';
77
+ export { SwipeRow, type SwipeRowProps, type SwipeAction } from './components/SwipeRow';
78
+ export { ToastProvider, useToast, type ToastApi, type ToastOptions, type ToastTone } from './components/Toast';
79
+ export { MessageBubble, type MessageBubbleProps } from './components/MessageBubble';
80
+ export { ChatComposer, type ChatComposerProps } from './components/ChatComposer';
81
+ export { DataList, type DataListProps } from './components/DataList';
82
+ export { SectionedList, type SectionedListProps } from './components/SectionedList';
83
+ export type { ServerListProps, ListSearch, ListSort, FilterGroup, FilterOption } from './components/serverListTypes';
84
+ export { resolveListState, createLoadMoreGuard, createDebouncer, summarizeFilters, type ListViewState } from './logic/listState';
@@ -0,0 +1,3 @@
1
+ export function canSend(text: string, sending: boolean): boolean {
2
+ return !sending && text.trim().length > 0;
3
+ }
@@ -0,0 +1,77 @@
1
+ /** Pure state logic for server-driven lists. Nothing here ever sees the full
2
+ * dataset: search, filter, sort and paging all belong to the server. */
3
+
4
+ export type ListViewState = 'loading' | 'error' | 'noResults' | 'empty' | 'content';
5
+
6
+ export interface ListStateInput {
7
+ itemCount: number;
8
+ loading?: boolean;
9
+ refreshing?: boolean;
10
+ error?: unknown;
11
+ /** True when a search query or filter is applied (empty then means "no results"). */
12
+ hasActiveQuery?: boolean;
13
+ }
14
+
15
+ export function resolveListState(i: ListStateInput): ListViewState {
16
+ if (i.itemCount > 0) return 'content';
17
+ if (i.error) return 'error';
18
+ if (i.loading && !i.refreshing) return 'loading';
19
+ return i.hasActiveQuery ? 'noResults' : 'empty';
20
+ }
21
+
22
+ export interface LoadMoreState {
23
+ hasMore: boolean;
24
+ loading: boolean;
25
+ loadingMore: boolean;
26
+ error: boolean;
27
+ }
28
+
29
+ /** Stops onEndReached (which fires repeatedly) from requesting the same page
30
+ * twice: one request per data length until the data grows or reset() is called. */
31
+ export function createLoadMoreGuard() {
32
+ let lastToken: number | null = null;
33
+ return {
34
+ tryStart(s: LoadMoreState, token: number): boolean {
35
+ if (!s.hasMore || s.loading || s.loadingMore || s.error) return false;
36
+ if (lastToken === token) return false;
37
+ lastToken = token;
38
+ return true;
39
+ },
40
+ reset() { lastToken = null; },
41
+ };
42
+ }
43
+
44
+ export function createDebouncer<T>(fn: (v: T) => void, ms: number) {
45
+ let timer: ReturnType<typeof setTimeout> | null = null;
46
+ let pending: { v: T } | null = null;
47
+ const clear = () => { if (timer) clearTimeout(timer); timer = null; };
48
+ const fire = () => { clear(); if (pending) { const { v } = pending; pending = null; fn(v); } };
49
+ return {
50
+ call(v: T) { pending = { v }; clear(); timer = setTimeout(fire, ms); },
51
+ flush: fire,
52
+ cancel() { clear(); pending = null; },
53
+ };
54
+ }
55
+
56
+ export interface FilterOption { value: string; label: string }
57
+ export interface FilterGroup {
58
+ key: string;
59
+ label: string;
60
+ options: FilterOption[];
61
+ /** Selected option value, or null when the filter is off. */
62
+ value: string | null;
63
+ /** 'chips' (default) for few options, 'select' opens a Sheet picker. */
64
+ display?: 'chips' | 'select';
65
+ }
66
+
67
+ export function summarizeFilters(groups: FilterGroup[], query = ''): { count: number; labels: string[] } {
68
+ const labels: string[] = [];
69
+ for (const g of groups) {
70
+ if (g.value == null) continue;
71
+ const opt = g.options.find((o) => o.value === g.value);
72
+ labels.push(`${g.label}: ${opt?.label ?? g.value}`);
73
+ }
74
+ const q = query.trim();
75
+ if (q) labels.push(`"${q}"`);
76
+ return { count: labels.length, labels };
77
+ }