@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,88 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { resolveListState, createLoadMoreGuard, createDebouncer, summarizeFilters } from '../logic/listState';
3
+
4
+ describe('resolveListState', () => {
5
+ it('loading only when nothing is shown yet', () => {
6
+ expect(resolveListState({ itemCount: 0, loading: true })).toBe('loading');
7
+ expect(resolveListState({ itemCount: 5, loading: true })).toBe('content');
8
+ });
9
+ it('refreshing an empty list is not the skeleton', () => {
10
+ expect(resolveListState({ itemCount: 0, loading: true, refreshing: true })).toBe('empty');
11
+ });
12
+ it('error wins over empty but not over content', () => {
13
+ expect(resolveListState({ itemCount: 0, error: 'boom' })).toBe('error');
14
+ expect(resolveListState({ itemCount: 2, error: 'boom' })).toBe('content');
15
+ });
16
+ it('empty with an active query/filter is noResults', () => {
17
+ expect(resolveListState({ itemCount: 0, hasActiveQuery: true })).toBe('noResults');
18
+ expect(resolveListState({ itemCount: 0 })).toBe('empty');
19
+ });
20
+ });
21
+
22
+ describe('createLoadMoreGuard', () => {
23
+ const ok = { hasMore: true, loading: false, loadingMore: false, error: false };
24
+ it('allows one fetch per data length (no duplicate pages)', () => {
25
+ const g = createLoadMoreGuard();
26
+ expect(g.tryStart(ok, 20)).toBe(true);
27
+ expect(g.tryStart(ok, 20)).toBe(false);
28
+ expect(g.tryStart(ok, 20)).toBe(false);
29
+ expect(g.tryStart(ok, 40)).toBe(true);
30
+ });
31
+ it('refuses when no more, loading, loadingMore or error', () => {
32
+ const g = createLoadMoreGuard();
33
+ expect(g.tryStart({ ...ok, hasMore: false }, 1)).toBe(false);
34
+ expect(g.tryStart({ ...ok, loading: true }, 2)).toBe(false);
35
+ expect(g.tryStart({ ...ok, loadingMore: true }, 3)).toBe(false);
36
+ expect(g.tryStart({ ...ok, error: true }, 4)).toBe(false);
37
+ });
38
+ it('refused attempts do not burn the token', () => {
39
+ const g = createLoadMoreGuard();
40
+ g.tryStart({ ...ok, loadingMore: true }, 20);
41
+ expect(g.tryStart(ok, 20)).toBe(true);
42
+ });
43
+ it('reset allows retrying the same length (refresh / retry / new query)', () => {
44
+ const g = createLoadMoreGuard();
45
+ g.tryStart(ok, 20);
46
+ g.reset();
47
+ expect(g.tryStart(ok, 20)).toBe(true);
48
+ });
49
+ });
50
+
51
+ describe('createDebouncer', () => {
52
+ beforeEach(() => vi.useFakeTimers());
53
+ afterEach(() => vi.useRealTimers());
54
+ it('fires once with the last value after the quiet period', () => {
55
+ const fn = vi.fn();
56
+ const d = createDebouncer(fn, 300);
57
+ d.call('a'); vi.advanceTimersByTime(200);
58
+ d.call('ab'); vi.advanceTimersByTime(299);
59
+ expect(fn).not.toHaveBeenCalled();
60
+ vi.advanceTimersByTime(1);
61
+ expect(fn).toHaveBeenCalledTimes(1);
62
+ expect(fn).toHaveBeenCalledWith('ab');
63
+ });
64
+ it('flush fires immediately, cancel drops', () => {
65
+ const fn = vi.fn();
66
+ const d = createDebouncer(fn, 300);
67
+ d.call('x'); d.flush();
68
+ expect(fn).toHaveBeenCalledWith('x');
69
+ d.call('y'); d.cancel(); vi.advanceTimersByTime(1000);
70
+ expect(fn).toHaveBeenCalledTimes(1);
71
+ });
72
+ });
73
+
74
+ describe('summarizeFilters', () => {
75
+ const groups = [
76
+ { key: 'project', label: 'Project', options: [{ value: 'p1', label: 'Alpha' }], value: 'p1' },
77
+ { key: 'status', label: 'Status', options: [{ value: 'open', label: 'Open' }], value: null },
78
+ ];
79
+ it('lists only active groups with option labels', () => {
80
+ expect(summarizeFilters(groups)).toEqual({ count: 1, labels: ['Project: Alpha'] });
81
+ });
82
+ it('counts a search query', () => {
83
+ expect(summarizeFilters(groups, 'milk')).toEqual({ count: 2, labels: ['Project: Alpha', '"milk"'] });
84
+ });
85
+ it('empty when nothing active', () => {
86
+ expect(summarizeFilters([groups[1]], ' ')).toEqual({ count: 0, labels: [] });
87
+ });
88
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseMarkdown, parseInline } from '../logic/markdown';
3
+
4
+ describe('parseInline', () => {
5
+ it('plain text', () => expect(parseInline('hi')).toEqual([{ type: 'text', text: 'hi' }]));
6
+ it('bold, italic, code', () => {
7
+ expect(parseInline('a **b** *c* `d`')).toEqual([
8
+ { type: 'text', text: 'a ' },
9
+ { type: 'bold', children: [{ type: 'text', text: 'b' }] },
10
+ { type: 'text', text: ' ' },
11
+ { type: 'italic', children: [{ type: 'text', text: 'c' }] },
12
+ { type: 'text', text: ' ' },
13
+ { type: 'code', text: 'd' },
14
+ ]);
15
+ });
16
+ it('links keep http(s)/mailto/tel only', () => {
17
+ expect(parseInline('[x](https://a.com)')).toEqual([{ type: 'link', href: 'https://a.com', children: [{ type: 'text', text: 'x' }] }]);
18
+ expect(parseInline('[x](javascript:alert(1))')).toEqual([{ type: 'text', text: 'x' }]);
19
+ });
20
+ it('unclosed markers stay literal', () => expect(parseInline('a **b')).toEqual([{ type: 'text', text: 'a **b' }]));
21
+ it('bold with nested italic', () => {
22
+ expect(parseInline('**a *b***')).toEqual([{ type: 'bold', children: [{ type: 'text', text: 'a ' }, { type: 'italic', children: [{ type: 'text', text: 'b' }] }] }]);
23
+ });
24
+ });
25
+
26
+ describe('parseMarkdown', () => {
27
+ it('headings, paragraphs, bullets, numbered, quote, rule, code', () => {
28
+ const src = ['# T', '', 'para one', 'still one', '', '- a', '* b **x**', '', '1. one', '2. two', '', '> q', '', '---', '', '```', 'code **raw**', '```'].join('\n');
29
+ const b = parseMarkdown(src);
30
+ expect(b.map((x) => x.type)).toEqual(['heading', 'paragraph', 'bullets', 'numbered', 'quote', 'rule', 'code']);
31
+ expect(b[0]).toMatchObject({ type: 'heading', level: 1 });
32
+ expect(b[1]).toEqual({ type: 'paragraph', inlines: [{ type: 'text', text: 'para one\nstill one' }] });
33
+ expect((b[2] as any).items).toHaveLength(2);
34
+ expect(b[6]).toEqual({ type: 'code', text: 'code **raw**' });
35
+ });
36
+ it('heading levels clamp to 3', () => expect(parseMarkdown('#### deep')[0]).toMatchObject({ type: 'heading', level: 3 }));
37
+ it('empty / whitespace gives no blocks', () => expect(parseMarkdown(' \n\n')).toEqual([]));
38
+ it('unterminated code fence consumes the rest', () => expect(parseMarkdown('```\na\nb')).toEqual([{ type: 'code', text: 'a\nb' }]));
39
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { clampSwipe, settleSwipe } from '../logic/swipe';
3
+ import { createToastStore } from '../logic/toast';
4
+ import { canSend } from '../logic/chat';
5
+ import { statusTone, formatProgress } from '../logic/status';
6
+ import { filterOptions, selectedLabel } from '../logic/select';
7
+
8
+ describe('swipe', () => {
9
+ it('clamps between -actionsWidth (open) and 0', () => {
10
+ expect(clampSwipe(-300, 160)).toBe(-160);
11
+ expect(clampSwipe(40, 160)).toBe(0);
12
+ expect(clampSwipe(-50, 160)).toBe(-50);
13
+ });
14
+ it('settles open past half or on a fast left fling, closed otherwise', () => {
15
+ expect(settleSwipe(-90, 0, 160)).toBe('open');
16
+ expect(settleSwipe(-30, -0.8, 160)).toBe('open');
17
+ expect(settleSwipe(-90, 0.8, 160)).toBe('closed');
18
+ expect(settleSwipe(-30, 0, 160)).toBe('closed');
19
+ });
20
+ });
21
+
22
+ describe('toast store', () => {
23
+ it('show/dismiss/clear notify subscribers', () => {
24
+ let n = 0;
25
+ const s = createToastStore();
26
+ s.subscribe(() => { n++; });
27
+ const a = s.show('Saved');
28
+ const b = s.show('Oops', { tone: 'error', durationMs: 0 });
29
+ expect(s.getToasts().map((t) => [t.id, t.message, t.tone])).toEqual([[a, 'Saved', 'info'], [b, 'Oops', 'error']]);
30
+ expect(s.getToasts()[0].durationMs).toBe(3000);
31
+ s.dismiss(a);
32
+ expect(s.getToasts()).toHaveLength(1);
33
+ s.clear();
34
+ expect(s.getToasts()).toEqual([]);
35
+ expect(n).toBe(4);
36
+ });
37
+ it('unsubscribe stops notifications; dismissing unknown id is a no-op', () => {
38
+ let n = 0;
39
+ const s = createToastStore();
40
+ const off = s.subscribe(() => { n++; });
41
+ off();
42
+ s.show('x'); s.dismiss('nope');
43
+ expect(n).toBe(0);
44
+ });
45
+ });
46
+
47
+ describe('canSend', () => {
48
+ it('needs non-blank text and not already sending', () => {
49
+ expect(canSend('hi', false)).toBe(true);
50
+ expect(canSend(' \n', false)).toBe(false);
51
+ expect(canSend('hi', true)).toBe(false);
52
+ expect(canSend('', false)).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe('status', () => {
57
+ it('maps domain statuses onto badge variants', () => {
58
+ expect(statusTone('queued')).toEqual({ variant: 'secondary', label: 'Queued' });
59
+ expect(statusTone('processing').variant).toBe('default');
60
+ expect(statusTone('Transcribing').variant).toBe('default');
61
+ expect(statusTone('done')).toEqual({ variant: 'success', label: 'Done' });
62
+ expect(statusTone('ready').variant).toBe('success');
63
+ expect(statusTone('failed')).toEqual({ variant: 'destructive', label: 'Failed' });
64
+ expect(statusTone('partial').variant).toBe('warning');
65
+ expect(statusTone('weird_thing')).toEqual({ variant: 'outline', label: 'Weird thing' });
66
+ });
67
+ it('formats progress as a clamped percent', () => {
68
+ expect(formatProgress(0.4)).toBe('40%');
69
+ expect(formatProgress(2)).toBe('100%');
70
+ expect(formatProgress(-1)).toBe('0%');
71
+ expect(formatProgress(Number.NaN)).toBe('0%');
72
+ });
73
+ });
74
+
75
+ describe('select', () => {
76
+ const opts = [{ value: 'a', label: 'Alpha', description: 'first' }, { value: 'b', label: 'Beta' }];
77
+ it('filters by label/description, case-insensitive', () => {
78
+ expect(filterOptions(opts, 'ALP')).toEqual([opts[0]]);
79
+ expect(filterOptions(opts, 'first')).toEqual([opts[0]]);
80
+ expect(filterOptions(opts, ' ')).toEqual(opts);
81
+ });
82
+ it('selectedLabel falls back to null', () => {
83
+ expect(selectedLabel(opts, 'b')).toBe('Beta');
84
+ expect(selectedLabel(opts, null)).toBeNull();
85
+ expect(selectedLabel(opts, 'zzz')).toBeNull();
86
+ });
87
+ });
@@ -0,0 +1,14 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { errorTextColor, toastStyle, swipeActionStyle } from '../styles-overlay';
3
+ import { lightTheme, darkTheme } from '../theme';
4
+
5
+ describe('styles-overlay', () => {
6
+ it('error text is the token in light and a brighter red in dark', () => {
7
+ expect(errorTextColor(lightTheme)).toBe(lightTheme.colors.destructive);
8
+ expect(errorTextColor(darkTheme)).not.toBe(darkTheme.colors.destructive);
9
+ });
10
+ it('toast and swipe actions follow the tone tokens', () => {
11
+ expect(toastStyle(lightTheme, 'success').backgroundColor).toBe(lightTheme.colors.success);
12
+ expect(swipeActionStyle(darkTheme, 'destructive', 80)).toMatchObject({ width: 80, backgroundColor: darkTheme.colors.destructive });
13
+ });
14
+ });
@@ -0,0 +1,65 @@
1
+ import { useState } from 'react';
2
+ import { KeyboardAvoidingView, Platform, Pressable, Text as RNText, View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { Input } from './Input';
4
+ import { useTheme } from './ThemeProvider';
5
+ import { composerStyle, sendButtonStyle, sendGlyphStyle } from '../styles-chat';
6
+ import { canSend } from '../logic/chat';
7
+
8
+ export interface ChatComposerProps {
9
+ onSend: (text: string) => void;
10
+ /** Controlled value. Omit to let the composer own (and clear) its text. */
11
+ value?: string;
12
+ onChangeText?: (text: string) => void;
13
+ placeholder?: string;
14
+ /** Disables sending while an answer is in flight. */
15
+ sending?: boolean;
16
+ /** Extra offset for headers above the keyboard-avoiding view. */
17
+ keyboardVerticalOffset?: number;
18
+ /** Bottom safe-area padding (home indicator). */
19
+ bottomInset?: number;
20
+ style?: StyleProp<ViewStyle>;
21
+ testID?: string;
22
+ }
23
+
24
+ /** Keyboard-avoiding message input with a send button. Trims text on send. */
25
+ export function ChatComposer({ onSend, value, onChangeText, placeholder = 'Message', sending = false, keyboardVerticalOffset = 0, bottomInset = 0, style, testID }: ChatComposerProps) {
26
+ const theme = useTheme();
27
+ const [own, setOwn] = useState('');
28
+ const controlled = value !== undefined;
29
+ const text = controlled ? value : own;
30
+ const enabled = canSend(text, sending);
31
+ const id = (s: string) => (testID ? `${testID}-${s}` : undefined);
32
+
33
+ const submit = () => {
34
+ if (!enabled) return;
35
+ onSend(text.trim());
36
+ if (!controlled) setOwn('');
37
+ };
38
+
39
+ return (
40
+ <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={keyboardVerticalOffset}>
41
+ <View style={[composerStyle(theme), { paddingBottom: bottomInset + theme.spacing.sm }, style]}>
42
+ <Input
43
+ testID={id('input')}
44
+ value={text}
45
+ onChangeText={(t) => { if (!controlled) setOwn(t); onChangeText?.(t); }}
46
+ placeholder={placeholder}
47
+ accessibilityLabel={placeholder}
48
+ multiline
49
+ style={{ flex: 1, maxHeight: 120, borderRadius: theme.radii.xl }}
50
+ />
51
+ <Pressable
52
+ testID={id('send')}
53
+ accessibilityRole="button"
54
+ accessibilityLabel="Send message"
55
+ accessibilityState={{ disabled: !enabled }}
56
+ disabled={!enabled}
57
+ onPress={submit}
58
+ style={sendButtonStyle(theme, enabled)}
59
+ >
60
+ <RNText style={sendGlyphStyle(theme, enabled)}>↑</RNText>
61
+ </Pressable>
62
+ </View>
63
+ </KeyboardAvoidingView>
64
+ );
65
+ }
@@ -7,17 +7,21 @@ export interface ChipProps {
7
7
  selected?: boolean;
8
8
  onPress?: () => void;
9
9
  style?: StyleProp<ViewStyle>;
10
+ testID?: string;
11
+ accessibilityLabel?: string;
10
12
  }
11
13
 
12
14
  /** Selectable filter pill. Filled with the primary color when selected,
13
15
  * outlined otherwise. Compose a row of these for a filter bar. */
14
- export function Chip({ label, selected = false, onPress, style }: ChipProps) {
16
+ export function Chip({ label, selected = false, onPress, style, testID, accessibilityLabel }: ChipProps) {
15
17
  const theme = useTheme();
16
18
  return (
17
19
  <Pressable
18
20
  accessibilityRole="button"
19
21
  accessibilityState={{ selected }}
20
22
  onPress={onPress}
23
+ testID={testID}
24
+ accessibilityLabel={accessibilityLabel}
21
25
  style={({ pressed }) => [chipStyle(theme, selected), pressed ? { opacity: 0.7 } : null, style]}
22
26
  >
23
27
  <RNText style={chipTextStyle(theme, selected)}>{label}</RNText>
@@ -0,0 +1,52 @@
1
+ import type { ReactElement } from 'react';
2
+ import { FlatList, View, type FlatListProps, type ListRenderItem } from 'react-native';
3
+ import { ListControls } from './ListControls';
4
+ import { useServerList } from './useServerList';
5
+ import { useTheme } from './ThemeProvider';
6
+ import type { ServerListProps } from './serverListTypes';
7
+
8
+ export type DataListProps<T> = ServerListProps &
9
+ Pick<FlatListProps<T>, 'keyExtractor' | 'extraData' | 'ItemSeparatorComponent' | 'contentContainerStyle'> & {
10
+ data: readonly T[];
11
+ renderItem: ListRenderItem<T>;
12
+ };
13
+
14
+ /**
15
+ * List for server-driven data: debounced search, sort + filter controls with an
16
+ * active-filter summary, pull-to-refresh, infinite scroll with a hasMore /
17
+ * loadingMore footer, and distinct loading / empty / no-results / error states.
18
+ * It renders exactly the rows it is given: filtering, sorting and paging are the
19
+ * server's job, reported through the callbacks.
20
+ */
21
+ export function DataList<T>(props: DataListProps<T>): ReactElement {
22
+ const { data, renderItem, keyExtractor, extraData, ItemSeparatorComponent, contentContainerStyle, ListHeaderComponent, testID, search, filters, onFilterChange, onClearFilters, sort } = props;
23
+ const theme = useTheme();
24
+ const { empty, footer, onEndReached, refreshControl } = useServerList(props, data.length);
25
+ const hasControls = !!(search || sort || filters?.length);
26
+ return (
27
+ <View style={{ flex: 1 }} testID={testID ? `${testID}-root` : undefined}>
28
+ {hasControls ? (
29
+ <View style={{ paddingHorizontal: theme.spacing.lg, paddingTop: theme.spacing.sm }}>
30
+ <ListControls search={search} filters={filters} onFilterChange={onFilterChange} onClearFilters={onClearFilters} sort={sort} testID={testID} />
31
+ </View>
32
+ ) : null}
33
+ <FlatList
34
+ testID={testID}
35
+ data={data}
36
+ renderItem={renderItem}
37
+ keyExtractor={keyExtractor}
38
+ extraData={extraData}
39
+ ItemSeparatorComponent={ItemSeparatorComponent}
40
+ ListHeaderComponent={ListHeaderComponent ? <>{ListHeaderComponent}</> : undefined}
41
+ ListEmptyComponent={empty ?? undefined}
42
+ ListFooterComponent={footer ?? undefined}
43
+ onEndReached={onEndReached}
44
+ onEndReachedThreshold={0.5}
45
+ refreshControl={refreshControl}
46
+ keyboardShouldPersistTaps="handled"
47
+ keyboardDismissMode="on-drag"
48
+ contentContainerStyle={[{ paddingHorizontal: theme.spacing.lg, paddingBottom: theme.spacing.xl, flexGrow: 1 }, contentContainerStyle]}
49
+ />
50
+ </View>
51
+ );
52
+ }
@@ -14,6 +14,9 @@ export interface EmptyStateProps {
14
14
  onAction?: () => void;
15
15
  /** Layout overrides (e.g. margins), merged after the base style. */
16
16
  style?: StyleProp<ViewStyle>;
17
+ testID?: string;
18
+ /** testID for the action button. */
19
+ actionTestID?: string;
17
20
  }
18
21
 
19
22
  /**
@@ -22,10 +25,11 @@ export interface EmptyStateProps {
22
25
  * leaving a screen blank (or showing a bare table header) when there's nothing
23
26
  * to display yet.
24
27
  */
25
- export function EmptyState({ icon, title, description, actionLabel, onAction, style }: EmptyStateProps) {
28
+ export function EmptyState({ icon, title, description, actionLabel, onAction, style, testID, actionTestID }: EmptyStateProps) {
26
29
  const theme = useTheme();
27
30
  return (
28
31
  <View
32
+ testID={testID}
29
33
  style={[
30
34
  {
31
35
  alignItems: 'center',
@@ -55,6 +59,7 @@ export function EmptyState({ icon, title, description, actionLabel, onAction, st
55
59
  title={actionLabel}
56
60
  variant="primary"
57
61
  onPress={onAction}
62
+ testID={actionTestID}
58
63
  style={{ marginTop: theme.spacing.sm, alignSelf: 'stretch' }}
59
64
  />
60
65
  ) : null}
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Text as RNText, View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { Label } from './Label';
4
+ import { useTheme } from './ThemeProvider';
5
+ import { errorTextColor } from '../styles-overlay';
6
+
7
+ export interface FormFieldProps {
8
+ label?: string;
9
+ required?: boolean;
10
+ hint?: string;
11
+ /** When set, replaces the hint and is announced as an alert. */
12
+ error?: string;
13
+ /** Right-aligned text under the control, e.g. a character counter. */
14
+ footerRight?: string;
15
+ children: ReactNode;
16
+ style?: StyleProp<ViewStyle>;
17
+ }
18
+
19
+ /** Label + control + hint/error wrapper for any input. */
20
+ export function FormField({ label, required, hint, error, footerRight, children, style }: FormFieldProps) {
21
+ const theme = useTheme();
22
+ return (
23
+ <View style={[{ gap: theme.spacing.xs }, style]}>
24
+ {label ? <Label>{label}{required ? <RNText style={{ color: theme.colors.destructive }}> *</RNText> : null}</Label> : null}
25
+ {children}
26
+ {error || hint || footerRight ? (
27
+ <View style={{ flexDirection: 'row', justifyContent: 'space-between', gap: theme.spacing.sm }}>
28
+ {error ? (
29
+ <RNText accessibilityRole="alert" style={{ flex: 1, color: errorTextColor(theme), fontSize: theme.fontSizes.xs }}>{error}</RNText>
30
+ ) : (
31
+ <RNText style={{ flex: 1, color: theme.colors.mutedForeground, fontSize: theme.fontSizes.xs }}>{hint ?? ''}</RNText>
32
+ )}
33
+ {footerRight ? <RNText style={{ color: theme.colors.mutedForeground, fontSize: theme.fontSizes.xs }}>{footerRight}</RNText> : null}
34
+ </View>
35
+ ) : null}
36
+ </View>
37
+ );
38
+ }
@@ -0,0 +1,46 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Pressable, Text as RNText, View } from 'react-native';
3
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
+ import { useTheme } from './ThemeProvider';
5
+ import { headerStyle } from '../styles-overlay';
6
+ import { textStyle } from '../styles';
7
+
8
+ export interface HeaderProps {
9
+ title: string;
10
+ subtitle?: string;
11
+ /** Shows a back button when set. */
12
+ onBack?: () => void;
13
+ backLabel?: string;
14
+ /** Node pinned right, e.g. an action Button. */
15
+ trailing?: ReactNode;
16
+ /** Pad for the status bar / notch. Default false (Screen already does). */
17
+ safeArea?: boolean;
18
+ testID?: string;
19
+ }
20
+
21
+ /** Stack-screen header: back button, title/subtitle, trailing actions. */
22
+ export function Header({ title, subtitle, onBack, backLabel = 'Back', trailing, safeArea = false, testID }: HeaderProps) {
23
+ const theme = useTheme();
24
+ const insets = useSafeAreaInsets();
25
+ return (
26
+ <View testID={testID} style={headerStyle(theme, safeArea ? insets.top : 0)}>
27
+ {onBack ? (
28
+ <Pressable
29
+ testID={testID ? `${testID}-back` : undefined}
30
+ accessibilityRole="button"
31
+ accessibilityLabel={backLabel}
32
+ hitSlop={8}
33
+ onPress={onBack}
34
+ style={{ width: 44, height: 44, marginLeft: -theme.spacing.sm, alignItems: 'center', justifyContent: 'center' }}
35
+ >
36
+ <RNText style={{ color: theme.colors.primary, fontSize: 32, lineHeight: 34 }}>‹</RNText>
37
+ </Pressable>
38
+ ) : null}
39
+ <View style={{ flex: 1 }}>
40
+ <RNText accessibilityRole="header" numberOfLines={1} style={[textStyle(theme, 'heading'), { fontSize: theme.fontSizes.lg }]}>{title}</RNText>
41
+ {subtitle ? <RNText numberOfLines={1} style={textStyle(theme, 'caption')}>{subtitle}</RNText> : null}
42
+ </View>
43
+ {trailing ? <View style={{ flexDirection: 'row', alignItems: 'center', gap: theme.spacing.sm }}>{trailing}</View> : null}
44
+ </View>
45
+ );
46
+ }
@@ -0,0 +1,85 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { Pressable, ScrollView, Text as RNText, View } from 'react-native';
3
+ import { Chip } from './Chip';
4
+ import { SearchBar } from './SearchBar';
5
+ import { SegmentedControl } from './SegmentedControl';
6
+ import { Select } from './Select';
7
+ import { useTheme } from './ThemeProvider';
8
+ import { createDebouncer, summarizeFilters } from '../logic/listState';
9
+ import type { ServerListProps } from './serverListTypes';
10
+
11
+ type Props = Pick<ServerListProps, 'search' | 'filters' | 'onFilterChange' | 'onClearFilters' | 'sort' | 'testID'>;
12
+
13
+ /** Search, sort, filter chips and the active-filter summary shared by the server lists. */
14
+ export function ListControls({ search, filters = [], onFilterChange, onClearFilters, sort, testID }: Props) {
15
+ const theme = useTheme();
16
+ const id = (s: string) => (testID ? `${testID}-${s}` : undefined);
17
+ const latest = useRef(search);
18
+ latest.current = search;
19
+ const debouncer = useRef(createDebouncer<string>((t) => latest.current?.onSearch(t), search?.debounceMs ?? 300)).current;
20
+ useEffect(() => () => debouncer.cancel(), [debouncer]);
21
+
22
+ const summary = summarizeFilters(filters, search?.value ?? '');
23
+
24
+ return (
25
+ <View style={{ gap: theme.spacing.sm, paddingBottom: theme.spacing.sm }}>
26
+ {search ? (
27
+ <SearchBar
28
+ testID={id('search')}
29
+ value={search.value}
30
+ placeholder={search.placeholder}
31
+ onChangeText={(t) => { search.onChange(t); debouncer.call(t); }}
32
+ onSubmit={(t) => { debouncer.cancel(); search.onSearch(t); }}
33
+ onClear={() => debouncer.flush()}
34
+ />
35
+ ) : null}
36
+ {sort ? <SegmentedControl testID={id('sort')} options={sort.options} value={sort.value} onChange={sort.onChange} /> : null}
37
+ {filters.length ? (
38
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="handled" contentContainerStyle={{ gap: theme.spacing.sm, alignItems: 'center' }}>
39
+ {filters.map((g) =>
40
+ g.display === 'select' ? (
41
+ <Select
42
+ key={g.key}
43
+ compact
44
+ testID={id(`filter-${g.key}`)}
45
+ label={g.label}
46
+ options={g.options}
47
+ value={g.value}
48
+ clearLabel={`All ${g.label.toLowerCase()}`}
49
+ onChange={(v) => onFilterChange?.(g.key, v)}
50
+ />
51
+ ) : (
52
+ g.options.map((o) => {
53
+ const active = g.value === o.value;
54
+ return (
55
+ <Chip
56
+ key={`${g.key}:${o.value}`}
57
+ testID={id(`filter-${g.key}-${o.value}`)}
58
+ label={o.label}
59
+ selected={active}
60
+ accessibilityLabel={`${g.label}: ${o.label}`}
61
+ onPress={() => onFilterChange?.(g.key, active ? null : o.value)}
62
+ />
63
+ );
64
+ })
65
+ ),
66
+ )}
67
+ </ScrollView>
68
+ ) : null}
69
+ {summary.count > 0 && onClearFilters ? (
70
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: theme.spacing.sm }}>
71
+ <RNText numberOfLines={2} style={{ flex: 1, color: theme.colors.mutedForeground, fontSize: theme.fontSizes.xs }}>{summary.labels.join(' · ')}</RNText>
72
+ <Pressable
73
+ testID={id('clear-filters')}
74
+ accessibilityRole="button"
75
+ accessibilityLabel={`Clear ${summary.count} filter${summary.count === 1 ? '' : 's'}`}
76
+ hitSlop={8}
77
+ onPress={onClearFilters}
78
+ >
79
+ <RNText style={{ color: theme.colors.primary, fontSize: theme.fontSizes.sm, fontWeight: '600' }}>Clear</RNText>
80
+ </Pressable>
81
+ </View>
82
+ ) : null}
83
+ </View>
84
+ );
85
+ }