@startsimpli/ui-native 0.1.0 → 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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -2
  3. package/package.json +21 -9
  4. package/src/__tests__/forms.test.tsx +169 -0
  5. package/src/__tests__/interactive.test.tsx +117 -0
  6. package/src/__tests__/lists.test.tsx +190 -0
  7. package/src/__tests__/logic-list.test.ts +88 -0
  8. package/src/__tests__/logic-markdown.test.ts +39 -0
  9. package/src/__tests__/logic-misc.test.ts +87 -0
  10. package/src/__tests__/styles-overlay.test.ts +14 -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/TextArea.tsx +38 -0
  28. package/src/components/Toast.tsx +61 -0
  29. package/src/components/serverListTypes.ts +53 -0
  30. package/src/components/useServerList.tsx +58 -0
  31. package/src/index.ts +20 -0
  32. package/src/logic/chat.ts +3 -0
  33. package/src/logic/listState.ts +77 -0
  34. package/src/logic/markdown.ts +112 -0
  35. package/src/logic/select.ts +12 -0
  36. package/src/logic/status.ts +27 -0
  37. package/src/logic/swipe.ts +13 -0
  38. package/src/logic/toast.ts +28 -0
  39. package/src/styles-chat.ts +51 -0
  40. package/src/styles-overlay.ts +100 -0
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }