@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 StartSimpli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -69,6 +69,19 @@ export default function App() {
69
69
  `theme={lightTheme | darkTheme | customTheme}` to pin it) and mounts a
70
70
  `SafeAreaProvider` so `Screen` can apply insets.
71
71
 
72
+ ## Tandem-screen primitives
73
+
74
+ `Markdown`, `Sheet`, `Select`, `FormField`, `TextArea`, `SearchBar`, `Header`,
75
+ `StatusChip`, `SwipeRow`, `ToastProvider`/`useToast`, `MessageBubble`,
76
+ `ChatComposer`, `DataList` and `SectionedList`. Core React Native only (no
77
+ gesture-handler, reanimated or markdown dependency).
78
+
79
+ `DataList` / `SectionedList` are built for server-driven data and never filter,
80
+ sort or slice rows themselves: debounced `search.onSearch`, `filters` +
81
+ `onFilterChange`, `sort`, an active-filter summary with `onClearFilters`,
82
+ `onRefresh`, `onLoadMore` guarded to one call per loaded length (`hasMore` /
83
+ `loadingMore` footer), and distinct loading / empty / no-results / error states.
84
+
72
85
  ## Design tokens
73
86
 
74
87
  Tokens are the single source for color, spacing, type scale, radii, and
@@ -168,6 +181,8 @@ build a Pressable wrapper, a card variant, a chart, or a token-aware style
168
181
  helper in an app, extend this package instead. The web equivalent lives in
169
182
  [`@startsimpli/ui`](../ui); keep the two in lock-step.
170
183
 
171
- ## License
184
+ ## Links
172
185
 
173
- Private package for the StartSimpli monorepo.
186
+ - Source: https://github.com/qosha1/start-simpli/tree/main/packages/ui-native
187
+ - Homepage: https://startsimpli.com
188
+ - License: MIT, see [LICENSE](./LICENSE)
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "@startsimpli/ui-native",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "React Native UI primitives for StartSimpli apps (View/Text/Pressable), fed by shared design tokens",
5
+ "license": "MIT",
6
+ "homepage": "https://startsimpli.com",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/qosha1/start-simpli.git",
10
+ "directory": "packages/ui-native"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/qosha1/start-simpli/issues"
14
+ },
5
15
  "main": "./src/index.ts",
6
16
  "types": "./src/index.ts",
7
17
  "exports": {
@@ -15,17 +25,12 @@
15
25
  },
16
26
  "files": [
17
27
  "src",
18
- "README.md"
28
+ "README.md",
29
+ "LICENSE"
19
30
  ],
20
31
  "publishConfig": {
21
32
  "access": "public"
22
33
  },
23
- "scripts": {
24
- "type-check": "tsc --noEmit",
25
- "test": "vitest run",
26
- "test:watch": "vitest",
27
- "clean": "rm -rf dist"
28
- },
29
34
  "peerDependencies": {
30
35
  "react": ">=18.0.0",
31
36
  "react-native": ">=0.74.0",
@@ -46,7 +51,14 @@
46
51
  "react-native": "0.85.3",
47
52
  "react-native-safe-area-context": "~5.7.0",
48
53
  "react-native-svg": "15.15.4",
54
+ "react-test-renderer": "19.2.5",
49
55
  "typescript": "^6.0.3",
50
56
  "vitest": "^4.1.5"
57
+ },
58
+ "scripts": {
59
+ "type-check": "tsc --noEmit",
60
+ "test": "vitest run",
61
+ "test:watch": "vitest",
62
+ "clean": "rm -rf dist"
51
63
  }
52
- }
64
+ }
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, renderDark, allText, hostById, maybeById, press, act, flat } from '../../test/render';
3
+ import { Markdown } from '../components/Markdown';
4
+ import { Sheet } from '../components/Sheet';
5
+ import { Select } from '../components/Select';
6
+ import { TextArea } from '../components/TextArea';
7
+ import { FormField } from '../components/FormField';
8
+ import { SearchBar } from '../components/SearchBar';
9
+ import { Header } from '../components/Header';
10
+ import { StatusChip } from '../components/StatusChip';
11
+ import * as RN from 'react-native';
12
+ import { Text } from '../components/Text';
13
+
14
+ describe('Markdown', () => {
15
+ const src = '# Title\n\nHello **bold** and [link](https://a.com)\n\n- one\n- two\n\n1. first';
16
+ it('renders headings as headers, list markers and bold weight', () => {
17
+ const r = render(<Markdown testID="md">{src}</Markdown>);
18
+ const t = allText(r);
19
+ expect(t).toContain('Title');
20
+ expect(t).toContain('•');
21
+ expect(t).toContain('1.');
22
+ const header = r.root.findAll((n) => n.props.accessibilityRole === 'header');
23
+ expect(header.length).toBe(1);
24
+ const bold = r.root.findAll((n) => n.type === 'Text' && flat(n.props.style).fontWeight === '700' && n.children.includes('bold'));
25
+ expect(bold.length).toBe(1);
26
+ });
27
+ it('links open via onLinkPress, default Linking, and take the primary colour per scheme', () => {
28
+ const calls: string[] = [];
29
+ const r = render(<Markdown onLinkPress={(u) => calls.push(u)}>{src}</Markdown>);
30
+ const link = r.root.findAll((n) => n.props.accessibilityRole === 'link')[0];
31
+ act(() => link.props.onPress());
32
+ expect(calls).toEqual(['https://a.com']);
33
+ const r2 = render(<Markdown>{src}</Markdown>);
34
+ act(() => r2.root.findAll((n) => n.props.accessibilityRole === 'link')[0].props.onPress());
35
+ expect((RN as any).opened).toContain('https://a.com');
36
+ const light = flat(link.props.style).color;
37
+ const dark = flat(renderDark(<Markdown>{src}</Markdown>).root.findAll((n) => n.props.accessibilityRole === 'link')[0].props.style).color;
38
+ expect(light).not.toBe(dark);
39
+ });
40
+ it('renders nothing for blank source', () => {
41
+ expect(allText(render(<Markdown>{' '}</Markdown>))).toBe('');
42
+ });
43
+ });
44
+
45
+ describe('Sheet', () => {
46
+ it('renders nothing when closed, title + content when open', () => {
47
+ expect(allText(render(<Sheet visible={false} onClose={() => {}} title="T"><Text>body</Text></Sheet>))).toBe('');
48
+ const r = render(<Sheet visible onClose={() => {}} title="Pick" testID="s"><Text>body</Text></Sheet>);
49
+ expect(allText(r)).toContain('Pick');
50
+ expect(allText(r)).toContain('body');
51
+ });
52
+ it('backdrop dismisses and is labelled', () => {
53
+ let closed = 0;
54
+ const r = render(<Sheet visible onClose={() => closed++} testID="s"><Text>b</Text></Sheet>);
55
+ const bd = hostById(r, 's-backdrop');
56
+ expect(bd.props.accessibilityLabel).toBe('Dismiss');
57
+ press(bd);
58
+ expect(closed).toBe(1);
59
+ });
60
+ });
61
+
62
+ describe('Select', () => {
63
+ const options = [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }, { value: 'c', label: 'Gamma' }];
64
+ it('shows placeholder then selected label; opens sheet; picks option', () => {
65
+ const picks: (string | null)[] = [];
66
+ const r = render(<Select testID="sel" label="Project" options={options} value={null} onChange={(v) => picks.push(v)} placeholder="Choose" />);
67
+ expect(allText(r)).toContain('Choose');
68
+ expect(maybeById(r, 'sel-sheet')).toHaveLength(0);
69
+ press(hostById(r, 'sel'));
70
+ expect(maybeById(r, 'sel-option-b')).toHaveLength(1);
71
+ press(hostById(r, 'sel-option-b'));
72
+ expect(picks).toEqual(['b']);
73
+ expect(maybeById(r, 'sel-option-b')).toHaveLength(0); // closed
74
+ });
75
+ it('trigger is an accessible button with the current value', () => {
76
+ const r = render(<Select testID="sel" label="Project" options={options} value="c" onChange={() => {}} />);
77
+ const t = hostById(r, 'sel');
78
+ expect(t.props.accessibilityRole).toBe('button');
79
+ expect(t.props.accessibilityLabel).toBe('Project: Gamma');
80
+ });
81
+ it('searchable filters the options', () => {
82
+ const r = render(<Select testID="sel" options={options} value={null} onChange={() => {}} searchable />);
83
+ press(hostById(r, 'sel'));
84
+ act(() => hostById(r, 'sel-search-input').props.onChangeText('gam'));
85
+ expect(maybeById(r, 'sel-option-a')).toHaveLength(0);
86
+ expect(maybeById(r, 'sel-option-c')).toHaveLength(1);
87
+ });
88
+ it('clearLabel offers an explicit "all" row that emits null', () => {
89
+ const picks: (string | null)[] = [];
90
+ const r = render(<Select testID="sel" options={options} value="a" onChange={(v) => picks.push(v)} clearLabel="All projects" />);
91
+ press(hostById(r, 'sel'));
92
+ press(hostById(r, 'sel-clear'));
93
+ expect(picks).toEqual([null]);
94
+ });
95
+ });
96
+
97
+ describe('TextArea + FormField', () => {
98
+ it('multiline input with label, hint and error alert', () => {
99
+ const r = render(<TextArea testID="ta" label="Notes" hint="Optional" error="Too short" value="x" onChangeText={() => {}} />);
100
+ const input = hostById(r, 'ta');
101
+ expect(input.props.multiline).toBe(true);
102
+ expect(input.props.accessibilityLabel).toBe('Notes');
103
+ expect(allText(r)).toContain('Notes');
104
+ expect(allText(r)).toContain('Too short');
105
+ expect(allText(r)).not.toContain('Optional'); // error replaces hint
106
+ expect(r.root.findAll((n) => n.props.accessibilityRole === 'alert').length).toBeGreaterThan(0);
107
+ });
108
+ it('shows a character counter with maxLength', () => {
109
+ const r = render(<TextArea testID="ta" value="abc" onChangeText={() => {}} maxLength={10} />);
110
+ expect(allText(r)).toContain('3/10');
111
+ });
112
+ it('FormField marks required and shows hint without error', () => {
113
+ const r = render(<FormField label="Name" required hint="Your name"><Text>child</Text></FormField>);
114
+ expect(allText(r)).toContain('Name');
115
+ expect(allText(r)).toContain('*');
116
+ expect(allText(r)).toContain('Your name');
117
+ expect(allText(r)).toContain('child');
118
+ });
119
+ });
120
+
121
+ describe('SearchBar', () => {
122
+ it('reports text, submit, and clears with an accessible button', () => {
123
+ const log: string[] = [];
124
+ const r = render(<SearchBar testID="sb" value="milk" onChangeText={(t) => log.push('t:' + t)} onSubmit={(t) => log.push('s:' + t)} onClear={() => log.push('clear')} />);
125
+ const input = hostById(r, 'sb');
126
+ expect(input.props.returnKeyType).toBe('search');
127
+ act(() => input.props.onChangeText('mil'));
128
+ act(() => input.props.onSubmitEditing({ nativeEvent: { text: 'mil' } }));
129
+ const clear = hostById(r, 'sb-clear');
130
+ expect(clear.props.accessibilityLabel).toBe('Clear search');
131
+ press(clear);
132
+ expect(log).toEqual(['t:mil', 's:milk', 't:', 'clear']);
133
+ });
134
+ it('no clear button when empty', () => {
135
+ expect(maybeById(render(<SearchBar testID="sb" value="" onChangeText={() => {}} />), 'sb-clear')).toHaveLength(0);
136
+ });
137
+ });
138
+
139
+ describe('Header', () => {
140
+ it('title, subtitle, back and trailing', () => {
141
+ let back = 0;
142
+ const r = render(<Header testID="h" title="Note" subtitle="Today" onBack={() => back++} trailing={<Text>Edit</Text>} />);
143
+ expect(allText(r)).toContain('Note');
144
+ expect(allText(r)).toContain('Today');
145
+ expect(allText(r)).toContain('Edit');
146
+ const b = hostById(r, 'h-back');
147
+ expect(b.props.accessibilityLabel).toBe('Back');
148
+ press(b);
149
+ expect(back).toBe(1);
150
+ expect(r.root.findAll((n) => n.props.accessibilityRole === 'header').length).toBeGreaterThan(0);
151
+ });
152
+ it('no back button without onBack', () => {
153
+ expect(maybeById(render(<Header testID="h" title="Home" />), 'h-back')).toHaveLength(0);
154
+ });
155
+ });
156
+
157
+ describe('StatusChip', () => {
158
+ it('maps status to label and variant colours', () => {
159
+ const ok = render(<StatusChip testID="c" status="done" />);
160
+ expect(allText(ok)).toContain('Done');
161
+ const bad = render(<StatusChip testID="c" status="failed" />);
162
+ expect(flat(hostById(ok, 'c').props.style).backgroundColor).not.toBe(flat(hostById(bad, 'c').props.style).backgroundColor);
163
+ });
164
+ it('shows progress percent and a labelled bar', () => {
165
+ const r = render(<StatusChip testID="c" status="processing" progress={0.42} />);
166
+ expect(allText(r)).toContain('42%');
167
+ expect(hostById(r, 'c').props.accessibilityLabel).toBe('Processing 42%');
168
+ });
169
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { render, allText, hostById, maybeById, press, act, flat, texts } from '../../test/render';
3
+ import { SwipeRow } from '../components/SwipeRow';
4
+ import { ToastProvider, useToast } from '../components/Toast';
5
+ import { MessageBubble } from '../components/MessageBubble';
6
+ import { ChatComposer } from '../components/ChatComposer';
7
+ import { Text } from '../components/Text';
8
+
9
+ describe('SwipeRow', () => {
10
+ const mk = (log: string[]) => (
11
+ <SwipeRow testID="row" actions={[
12
+ { key: 'done', label: 'Done', tone: 'success', onPress: () => log.push('done') },
13
+ { key: 'delete', label: 'Delete', tone: 'destructive', onPress: () => log.push('delete') },
14
+ ]}><Text>Buy milk</Text></SwipeRow>
15
+ );
16
+ it('renders content and tappable actions', () => {
17
+ const log: string[] = [];
18
+ const r = render(mk(log));
19
+ expect(allText(r)).toContain('Buy milk');
20
+ press(hostById(r, 'row-action-delete'));
21
+ press(hostById(r, 'row-action-done'));
22
+ expect(log).toEqual(['delete', 'done']);
23
+ });
24
+ it('exposes actions to VoiceOver without swiping', () => {
25
+ const log: string[] = [];
26
+ const r = render(mk(log));
27
+ const row = hostById(r, 'row');
28
+ expect(row.props.accessibilityActions).toEqual([{ name: 'done', label: 'Done' }, { name: 'delete', label: 'Delete' }]);
29
+ act(() => row.props.onAccessibilityAction({ nativeEvent: { actionName: 'delete' } }));
30
+ expect(log).toEqual(['delete']);
31
+ });
32
+ it('defaultOpen starts with the actions revealed', () => {
33
+ const r = render(<SwipeRow testID="row" defaultOpen actions={[{ key: 'a', label: 'A', onPress: () => {} }, { key: 'b', label: 'B', onPress: () => {} }]}><Text>x</Text></SwipeRow>);
34
+ const view = r.root.findAll((n) => n.props.__cfg)[0];
35
+ expect(view.props.style.transform[0].translateX.v).toBe(-168);
36
+ });
37
+ it('pan handler opens past half and closes on slow short drag', () => {
38
+ const r = render(mk([]));
39
+ const cfg = r.root.findAll((n) => n.props.__cfg)[0]?.props.__cfg ?? (r.root.findAll((n) => n.props.panHandlers)[0]?.props.panHandlers.__cfg);
40
+ expect(cfg).toBeTruthy();
41
+ expect(cfg.onMoveShouldSetPanResponder({}, { dx: -20, dy: 2 })).toBe(true);
42
+ expect(cfg.onMoveShouldSetPanResponder({}, { dx: 3, dy: 30 })).toBe(false); // vertical scroll wins
43
+ });
44
+ });
45
+
46
+ describe('Toast', () => {
47
+ beforeEach(() => vi.useFakeTimers());
48
+ afterEach(() => vi.useRealTimers());
49
+ function Trigger({ opts }: { opts?: any }) {
50
+ const toast = useToast();
51
+ return <Text testID="go" onPress={() => toast.show('Saved!', opts)}>go</Text>;
52
+ }
53
+ it('shows a toast, then auto-dismisses after durationMs', () => {
54
+ const r = render(<ToastProvider><Trigger /></ToastProvider>);
55
+ act(() => hostById(r, 'go').props.onPress());
56
+ expect(allText(r)).toContain('Saved!');
57
+ act(() => { vi.advanceTimersByTime(3001); });
58
+ expect(allText(r)).not.toContain('Saved!');
59
+ });
60
+ it('durationMs 0 sticks until tapped; role alert for errors', () => {
61
+ const r = render(<ToastProvider><Trigger opts={{ tone: 'error', durationMs: 0 }} /></ToastProvider>);
62
+ act(() => hostById(r, 'go').props.onPress());
63
+ act(() => { vi.advanceTimersByTime(60000); });
64
+ expect(allText(r)).toContain('Saved!');
65
+ const t = r.root.findAll((n) => n.props.accessibilityRole === 'alert')[0];
66
+ expect(t).toBeTruthy();
67
+ act(() => t.props.onPress());
68
+ expect(allText(r)).not.toContain('Saved!');
69
+ });
70
+ it('useToast without a provider throws a clear error', () => {
71
+ expect(() => render(<Trigger />)).toThrow(/ToastProvider/);
72
+ });
73
+ });
74
+
75
+ describe('MessageBubble', () => {
76
+ it('user bubbles right, assistant left, colours differ', () => {
77
+ const u = render(<MessageBubble testID="b" role="user" text="hi" />);
78
+ const a = render(<MessageBubble testID="b" role="assistant" text="hello" />);
79
+ expect(flat(hostById(u, 'b').props.style).alignSelf).toBe('flex-end');
80
+ expect(flat(hostById(a, 'b').props.style).alignSelf).toBe('flex-start');
81
+ expect(flat(hostById(u, 'b').props.style).backgroundColor).not.toBe(flat(hostById(a, 'b').props.style).backgroundColor);
82
+ });
83
+ it('assistant text renders markdown', () => {
84
+ const a = render(<MessageBubble testID="b" role="assistant" text={'- one\n- two'} />);
85
+ expect(allText(a)).toContain('•');
86
+ });
87
+ it('pending shows indicator, error offers retry', () => {
88
+ let retried = 0;
89
+ const p = render(<MessageBubble testID="b" role="assistant" text="" pending />);
90
+ expect(hostById(p, 'b').props.accessibilityLabel).toMatch(/typing|thinking/i);
91
+ const e = render(<MessageBubble testID="b" role="user" text="x" error onRetry={() => retried++} />);
92
+ press(hostById(e, 'b-retry'));
93
+ expect(retried).toBe(1);
94
+ });
95
+ });
96
+
97
+ describe('ChatComposer', () => {
98
+ it('keyboard-avoiding wrapper, send disabled until text', () => {
99
+ const sent: string[] = [];
100
+ const r = render(<ChatComposer testID="c" onSend={(t) => sent.push(t)} placeholder="Ask" />);
101
+ expect(r.root.findAllByType('KeyboardAvoidingView' as any).length).toBe(1);
102
+ const send = hostById(r, 'c-send');
103
+ expect(send.props.accessibilityLabel).toBe('Send message');
104
+ expect(send.props.disabled).toBe(true);
105
+ act(() => hostById(r, 'c-input').props.onChangeText(' hello '));
106
+ expect(hostById(r, 'c-send').props.disabled).toBe(false);
107
+ press(hostById(r, 'c-send'));
108
+ expect(sent).toEqual(['hello']);
109
+ expect(hostById(r, 'c-input').props.value).toBe(''); // uncontrolled clears
110
+ });
111
+ it('controlled mode does not clear; sending disables', () => {
112
+ const sent: string[] = [];
113
+ const r = render(<ChatComposer testID="c" value="q" onChangeText={() => {}} onSend={(t) => sent.push(t)} sending />);
114
+ expect(hostById(r, 'c-send').props.disabled).toBe(true);
115
+ expect(hostById(r, 'c-input').props.value).toBe('q');
116
+ });
117
+ });
@@ -0,0 +1,190 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { render, allText, hostById, maybeById, press, act } from '../../test/render';
3
+ import { DataList } from '../components/DataList';
4
+ import { SectionedList } from '../components/SectionedList';
5
+ import { Text } from '../components/Text';
6
+ import { ThemeProvider } from '../components/ThemeProvider';
7
+ import { create } from 'react-test-renderer';
8
+
9
+ type Row = { id: string; name: string };
10
+ const rows: Row[] = [{ id: '1', name: 'Alpha' }, { id: '2', name: 'Beta' }];
11
+ const base = {
12
+ testID: 'l',
13
+ data: rows,
14
+ keyExtractor: (r: Row) => r.id,
15
+ renderItem: ({ item }: { item: Row }) => <Text>{item.name}</Text>,
16
+ };
17
+
18
+ describe('DataList states', () => {
19
+ it('loading shows skeletons only when no rows', () => {
20
+ const r = render(<DataList {...base} data={[]} loading />);
21
+ expect(maybeById(r, 'l-loading')).toHaveLength(1);
22
+ const r2 = render(<DataList {...base} loading />);
23
+ expect(maybeById(r2, 'l-loading')).toHaveLength(0);
24
+ expect(allText(r2)).toContain('Alpha');
25
+ });
26
+ it('error with retry', () => {
27
+ let n = 0;
28
+ const r = render(<DataList {...base} data={[]} error="Network down" onRetry={() => n++} />);
29
+ expect(allText(r)).toContain('Network down');
30
+ press(hostById(r, 'l-retry'));
31
+ expect(n).toBe(1);
32
+ });
33
+ it('empty vs noResults are distinct copy; noResults offers clear', () => {
34
+ const e = render(<DataList {...base} data={[]} emptyTitle="Nothing yet" noResultsTitle="No matches" />);
35
+ expect(allText(e)).toContain('Nothing yet');
36
+ let cleared = 0;
37
+ const nr = render(<DataList {...base} data={[]} emptyTitle="Nothing yet" noResultsTitle="No matches" search={{ value: 'zzz', onChange: () => {}, onSearch: () => {} }} onClearFilters={() => cleared++} />);
38
+ expect(allText(nr)).toContain('No matches');
39
+ expect(allText(nr)).not.toContain('Nothing yet');
40
+ press(hostById(nr, 'l-empty-clear'));
41
+ expect(cleared).toBe(1);
42
+ });
43
+ it('renders exactly the rows it is given: no client-side filter or sort', () => {
44
+ const r = render(<DataList {...base} data={[{ id: '9', name: 'Zulu' }, ...rows]} search={{ value: 'alpha', onChange: () => {}, onSearch: () => {} }} />);
45
+ const t = allText(r);
46
+ expect(t.indexOf('Zulu')).toBeGreaterThan(-1);
47
+ expect(t.indexOf('Zulu')).toBeLessThan(t.indexOf('Alpha'));
48
+ expect(t).toContain('Beta');
49
+ });
50
+ });
51
+
52
+ describe('DataList search debounce', () => {
53
+ beforeEach(() => vi.useFakeTimers());
54
+ afterEach(() => vi.useRealTimers());
55
+ it('onChange is immediate, onSearch fires once after debounceMs', () => {
56
+ const changes: string[] = []; const searches: string[] = [];
57
+ const r = render(<DataList {...base} search={{ value: '', onChange: (t) => changes.push(t), onSearch: (t) => searches.push(t), debounceMs: 250 }} />);
58
+ const input = () => hostById(r, 'l-search');
59
+ act(() => input().props.onChangeText('m'));
60
+ act(() => { vi.advanceTimersByTime(100); });
61
+ act(() => input().props.onChangeText('mi'));
62
+ act(() => { vi.advanceTimersByTime(249); });
63
+ expect(searches).toEqual([]);
64
+ act(() => { vi.advanceTimersByTime(1); });
65
+ expect(changes).toEqual(['m', 'mi']);
66
+ expect(searches).toEqual(['mi']);
67
+ });
68
+ it('submit flushes immediately and does not double-fire', () => {
69
+ const searches: string[] = [];
70
+ const r = render(<DataList {...base} search={{ value: 'milk', onChange: () => {}, onSearch: (t) => searches.push(t), debounceMs: 250 }} />);
71
+ act(() => hostById(r, 'l-search').props.onChangeText('milk'));
72
+ act(() => hostById(r, 'l-search').props.onSubmitEditing({ nativeEvent: { text: 'milk' } }));
73
+ act(() => { vi.advanceTimersByTime(1000); });
74
+ expect(searches).toEqual(['milk']);
75
+ });
76
+ it('clear button searches empty immediately', () => {
77
+ const searches: string[] = []; const changes: string[] = [];
78
+ const r = render(<DataList {...base} search={{ value: 'milk', onChange: (t) => changes.push(t), onSearch: (t) => searches.push(t) }} />);
79
+ press(hostById(r, 'l-search-clear'));
80
+ expect(changes).toEqual(['']);
81
+ expect(searches).toEqual(['']);
82
+ });
83
+ });
84
+
85
+ describe('DataList filters, sort, summary', () => {
86
+ const filters = [
87
+ { key: 'status', label: 'Status', value: 'open', options: [{ value: 'open', label: 'Open' }, { value: 'done', label: 'Done' }] },
88
+ { key: 'project', label: 'Project', value: null, display: 'select' as const, options: [{ value: 'p1', label: 'Alpha' }] },
89
+ ];
90
+ it('chip tap selects; tapping the active chip clears that filter', () => {
91
+ const log: any[] = [];
92
+ const r = render(<DataList {...base} filters={filters} onFilterChange={(k, v) => log.push([k, v])} />);
93
+ press(hostById(r, 'l-filter-status-done'));
94
+ press(hostById(r, 'l-filter-status-open'));
95
+ expect(log).toEqual([['status', 'done'], ['status', null]]);
96
+ });
97
+ it('select-style groups open a sheet picker', () => {
98
+ const log: any[] = [];
99
+ const r = render(<DataList {...base} filters={filters} onFilterChange={(k, v) => log.push([k, v])} />);
100
+ press(hostById(r, 'l-filter-project'));
101
+ press(hostById(r, 'l-filter-project-option-p1'));
102
+ expect(log).toEqual([['project', 'p1']]);
103
+ });
104
+ it('active-filter summary with count, and clear-all', () => {
105
+ let cleared = 0;
106
+ const r = render(<DataList {...base} filters={filters} search={{ value: 'milk', onChange: () => {}, onSearch: () => {} }} onFilterChange={() => {}} onClearFilters={() => cleared++} />);
107
+ expect(allText(r)).toContain('Status: Open');
108
+ expect(allText(r)).toContain('"milk"');
109
+ const clear = hostById(r, 'l-clear-filters');
110
+ expect(clear.props.accessibilityLabel).toBe('Clear 2 filters');
111
+ press(clear);
112
+ expect(cleared).toBe(1);
113
+ });
114
+ it('no summary when nothing is active', () => {
115
+ const r = render(<DataList {...base} filters={[{ ...filters[0], value: null }]} onFilterChange={() => {}} onClearFilters={() => {}} />);
116
+ expect(maybeById(r, 'l-clear-filters')).toHaveLength(0);
117
+ });
118
+ it('sort control emits the chosen option', () => {
119
+ const log: string[] = [];
120
+ const r = render(<DataList {...base} sort={{ value: 'new', options: [{ value: 'new', label: 'Newest' }, { value: 'old', label: 'Oldest' }], onChange: (v) => log.push(v) }} />);
121
+ expect(allText(r)).toContain('Oldest');
122
+ press(hostById(r, 'l-sort-old'));
123
+ expect(log).toEqual(['old']);
124
+ });
125
+ });
126
+
127
+ describe('DataList paging and refresh', () => {
128
+ const list = (r: any) => r.root.findByType('FlatList' as any);
129
+ it('end reached fetches once per page length (no duplicate fetches)', () => {
130
+ let n = 0;
131
+ const r = render(<DataList {...base} hasMore onLoadMore={() => n++} />);
132
+ act(() => list(r).props.onEndReached());
133
+ act(() => list(r).props.onEndReached());
134
+ act(() => list(r).props.onEndReached());
135
+ expect(n).toBe(1);
136
+ r.update(<></>);
137
+ });
138
+ it('same instance: grows then fetches again; hasMore false / loadingMore block', () => {
139
+ let n = 0;
140
+ const wrap = (p: any) => <ThemeProvider><DataList {...base} onLoadMore={() => n++} {...p} /></ThemeProvider>;
141
+ const a = act;
142
+ let r: any;
143
+ a(() => { r = create(wrap({ hasMore: true })); });
144
+ const fl = () => r.root.findByType('FlatList');
145
+ a(() => fl().props.onEndReached());
146
+ a(() => r.update(wrap({ hasMore: true, loadingMore: true })));
147
+ a(() => fl().props.onEndReached());
148
+ a(() => r.update(wrap({ hasMore: true, data: [...rows, { id: '3', name: 'G' }] })));
149
+ a(() => fl().props.onEndReached());
150
+ a(() => r.update(wrap({ hasMore: false, data: [...rows, { id: '3', name: 'G' }, { id: '4', name: 'H' }] })));
151
+ a(() => fl().props.onEndReached());
152
+ expect(n).toBe(2);
153
+ });
154
+ it('footer: spinner while loadingMore, retry row on paging error', () => {
155
+ const r = render(<DataList {...base} hasMore loadingMore />);
156
+ expect(maybeById(r, 'l-footer-loading')).toHaveLength(1);
157
+ let retry = 0;
158
+ const e = render(<DataList {...base} hasMore error="Page failed" onRetry={() => retry++} />);
159
+ expect(allText(e)).toContain('Page failed');
160
+ expect(maybeById(e, 'l-footer-loading')).toHaveLength(0);
161
+ press(hostById(e, 'l-footer-retry'));
162
+ expect(retry).toBe(1);
163
+ });
164
+ it('pull to refresh wires refreshing/onRefresh', () => {
165
+ let n = 0;
166
+ const r = render(<DataList {...base} refreshing onRefresh={() => n++} />);
167
+ const rc = list(r).props.refreshControl;
168
+ expect(rc.props.refreshing).toBe(true);
169
+ rc.props.onRefresh();
170
+ expect(n).toBe(1);
171
+ });
172
+ });
173
+
174
+ describe('SectionedList', () => {
175
+ const sections = [{ title: 'Today', data: [{ id: '1', name: 'A' }] }, { title: 'Earlier', data: [{ id: '2', name: 'B' }] }];
176
+ it('renders section headers and rows; headers are accessibility headers', () => {
177
+ const r = render(<SectionedList testID="s" sections={sections} keyExtractor={(i: Row) => i.id} renderItem={({ item }: any) => <Text>{item.name}</Text>} />);
178
+ const t = allText(r);
179
+ expect(t).toContain('Today'); expect(t).toContain('Earlier'); expect(t).toContain('A'); expect(t).toContain('B');
180
+ expect(r.root.findAll((n) => n.props.accessibilityRole === 'header').length).toBe(2);
181
+ });
182
+ it('shares the empty/error states and paging guard', () => {
183
+ let n = 0;
184
+ const r = render(<SectionedList testID="s" sections={[]} emptyTitle="No groups" keyExtractor={(i: Row) => i.id} renderItem={() => null} hasMore onLoadMore={() => n++} />);
185
+ expect(allText(r)).toContain('No groups');
186
+ const l = r.root.findByType('SectionList' as any);
187
+ act(() => l.props.onEndReached()); act(() => l.props.onEndReached());
188
+ expect(n).toBe(1);
189
+ });
190
+ });