@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,112 @@
1
+ /** Small dependency-free markdown subset for summaries, answers and transcripts:
2
+ * headings, paragraphs, bullet/numbered lists, quotes, rules, fenced code, and
3
+ * inline bold / italic / code / links. Unknown syntax stays literal text. */
4
+
5
+ export type Inline =
6
+ | { type: 'text'; text: string }
7
+ | { type: 'bold'; children: Inline[] }
8
+ | { type: 'italic'; children: Inline[] }
9
+ | { type: 'code'; text: string }
10
+ | { type: 'link'; href: string; children: Inline[] };
11
+
12
+ export type Block =
13
+ | { type: 'heading'; level: 1 | 2 | 3; inlines: Inline[] }
14
+ | { type: 'paragraph'; inlines: Inline[] }
15
+ | { type: 'bullets'; items: Inline[][] }
16
+ | { type: 'numbered'; items: Inline[][] }
17
+ | { type: 'quote'; inlines: Inline[] }
18
+ | { type: 'code'; text: string }
19
+ | { type: 'rule' };
20
+
21
+ const SAFE_HREF = /^(https?:\/\/|mailto:|tel:)/i;
22
+
23
+ function pushText(out: Inline[], text: string) {
24
+ if (!text) return;
25
+ const last = out[out.length - 1];
26
+ if (last && last.type === 'text') last.text += text;
27
+ else out.push({ type: 'text', text });
28
+ }
29
+
30
+ export function parseInline(src: string): Inline[] {
31
+ const out: Inline[] = [];
32
+ let i = 0;
33
+ while (i < src.length) {
34
+ const rest = src.slice(i);
35
+ let m: RegExpMatchArray | null;
36
+ if ((m = rest.match(/^`([^`\n]+)`/))) {
37
+ out.push({ type: 'code', text: m[1] });
38
+ i += m[0].length;
39
+ } else if ((m = rest.match(/^\[([^\]\n]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/))) {
40
+ const children = parseInline(m[1]);
41
+ if (SAFE_HREF.test(m[2])) out.push({ type: 'link', href: m[2], children });
42
+ else children.forEach((c) => (c.type === 'text' ? pushText(out, c.text) : out.push(c)));
43
+ i += m[0].length;
44
+ } else if ((m = rest.match(/^\*\*(?=\S)([\s\S]+?)(?<=\S)\*\*(?!\*)/)) || (m = rest.match(/^\*\*(?=\S)([\s\S]+\*)(?<=\S)\*\*/))) {
45
+ out.push({ type: 'bold', children: parseInline(m[1]) });
46
+ i += m[0].length;
47
+ } else if ((m = rest.match(/^\*(?=[^\s*])([^*\n]+?)(?<=\S)\*/))) {
48
+ out.push({ type: 'italic', children: parseInline(m[1]) });
49
+ i += m[0].length;
50
+ } else {
51
+ // consume up to the next possible marker
52
+ const next = rest.slice(1).search(/[`[*]/);
53
+ const len = next === -1 ? rest.length : next + 1;
54
+ pushText(out, rest.slice(0, len));
55
+ i += len;
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ const BULLET = /^\s*[-*+]\s+(.*)$/;
62
+ const NUMBERED = /^\s*\d+[.)]\s+(.*)$/;
63
+ const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
64
+ const RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
65
+ const FENCE = /^\s*```/;
66
+
67
+ export function parseMarkdown(src: string): Block[] {
68
+ const lines = src.replace(/\r\n?/g, '\n').split('\n');
69
+ const blocks: Block[] = [];
70
+ let i = 0;
71
+ const startsBlock = (l: string) =>
72
+ HEADING.test(l) || BULLET.test(l) || NUMBERED.test(l) || FENCE.test(l) || /^\s*>/.test(l) || RULE.test(l);
73
+
74
+ while (i < lines.length) {
75
+ const line = lines[i];
76
+ if (!line.trim()) { i++; continue; }
77
+ if (FENCE.test(line)) {
78
+ const body: string[] = [];
79
+ i++;
80
+ while (i < lines.length && !FENCE.test(lines[i])) body.push(lines[i++]);
81
+ i++; // closing fence (or end)
82
+ blocks.push({ type: 'code', text: body.join('\n') });
83
+ continue;
84
+ }
85
+ let m: RegExpMatchArray | null;
86
+ if ((m = line.match(HEADING))) {
87
+ blocks.push({ type: 'heading', level: Math.min(3, m[1].length) as 1 | 2 | 3, inlines: parseInline(m[2]) });
88
+ i++; continue;
89
+ }
90
+ if (RULE.test(line)) { blocks.push({ type: 'rule' }); i++; continue; }
91
+ if (BULLET.test(line)) {
92
+ const items: Inline[][] = [];
93
+ while (i < lines.length && (m = lines[i].match(BULLET))) { items.push(parseInline(m[1])); i++; }
94
+ blocks.push({ type: 'bullets', items }); continue;
95
+ }
96
+ if (NUMBERED.test(line)) {
97
+ const items: Inline[][] = [];
98
+ while (i < lines.length && (m = lines[i].match(NUMBERED))) { items.push(parseInline(m[1])); i++; }
99
+ blocks.push({ type: 'numbered', items }); continue;
100
+ }
101
+ if (/^\s*>/.test(line)) {
102
+ const body: string[] = [];
103
+ while (i < lines.length && /^\s*>/.test(lines[i])) body.push(lines[i++].replace(/^\s*>\s?/, ''));
104
+ blocks.push({ type: 'quote', inlines: parseInline(body.join('\n')) });
105
+ continue;
106
+ }
107
+ const para: string[] = [];
108
+ while (i < lines.length && lines[i].trim() && !(para.length && startsBlock(lines[i]))) para.push(lines[i++]);
109
+ blocks.push({ type: 'paragraph', inlines: parseInline(para.join('\n')) });
110
+ }
111
+ return blocks;
112
+ }
@@ -0,0 +1,12 @@
1
+ export interface SelectOption { value: string; label: string; description?: string }
2
+
3
+ export function filterOptions(options: SelectOption[], query: string): SelectOption[] {
4
+ const q = query.trim().toLowerCase();
5
+ if (!q) return options;
6
+ return options.filter((o) => o.label.toLowerCase().includes(q) || (o.description ?? '').toLowerCase().includes(q));
7
+ }
8
+
9
+ export function selectedLabel(options: SelectOption[], value: string | null | undefined): string | null {
10
+ if (value == null) return null;
11
+ return options.find((o) => o.value === value)?.label ?? null;
12
+ }
@@ -0,0 +1,27 @@
1
+ import type { BadgeVariant } from '../styles';
2
+
3
+ const GROUPS: Array<[BadgeVariant, string[]]> = [
4
+ ['secondary', ['queued', 'pending', 'waiting', 'draft', 'new']],
5
+ ['default', ['processing', 'running', 'transcribing', 'in_progress', 'uploading', 'syncing', 'active']],
6
+ ['success', ['done', 'complete', 'completed', 'ready', 'success', 'synced', 'connected']],
7
+ ['destructive', ['failed', 'error', 'rejected', 'disconnected']],
8
+ ['warning', ['partial', 'warning', 'stale', 'needs_attention', 'paused']],
9
+ ];
10
+
11
+ const norm = (s: string) => s.trim().toLowerCase().replace(/[\s-]+/g, '_');
12
+ const title = (s: string) => {
13
+ const t = s.trim().replace(/[_-]+/g, ' ');
14
+ return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
15
+ };
16
+
17
+ /** Domain status string to a Badge variant + display label. Unknown statuses are outlined. */
18
+ export function statusTone(status: string): { variant: BadgeVariant; label: string } {
19
+ const key = norm(status);
20
+ for (const [variant, names] of GROUPS) if (names.includes(key)) return { variant, label: title(status) };
21
+ return { variant: 'outline', label: title(status) };
22
+ }
23
+
24
+ export function formatProgress(value: number): string {
25
+ const v = Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
26
+ return `${Math.round(v * 100)}%`;
27
+ }
@@ -0,0 +1,13 @@
1
+ /** Swipe-row offset math. Offsets are <= 0: 0 is closed, -actionsWidth is open. */
2
+ export function clampSwipe(offset: number, actionsWidth: number): number {
3
+ return Math.min(0, Math.max(-actionsWidth, offset));
4
+ }
5
+
6
+ const FLING = 0.5;
7
+
8
+ /** Where a released row should settle. `velocity` is px/ms, negative = leftward. */
9
+ export function settleSwipe(offset: number, velocity: number, actionsWidth: number): 'open' | 'closed' {
10
+ if (velocity <= -FLING) return 'open';
11
+ if (velocity >= FLING) return 'closed';
12
+ return -offset > actionsWidth / 2 ? 'open' : 'closed';
13
+ }
@@ -0,0 +1,28 @@
1
+ export type ToastTone = 'info' | 'success' | 'error' | 'warning';
2
+ export interface ToastItem { id: string; message: string; tone: ToastTone; durationMs: number }
3
+ export interface ToastOptions { tone?: ToastTone; durationMs?: number }
4
+
5
+ export const DEFAULT_TOAST_MS = 3000;
6
+
7
+ export function createToastStore() {
8
+ let toasts: ToastItem[] = [];
9
+ let seq = 0;
10
+ const subs = new Set<() => void>();
11
+ const emit = () => subs.forEach((f) => f());
12
+ return {
13
+ getToasts: () => toasts,
14
+ show(message: string, opts: ToastOptions = {}): string {
15
+ const id = `toast-${++seq}`;
16
+ toasts = [...toasts, { id, message, tone: opts.tone ?? 'info', durationMs: opts.durationMs ?? DEFAULT_TOAST_MS }];
17
+ emit();
18
+ return id;
19
+ },
20
+ dismiss(id: string) {
21
+ if (!toasts.some((t) => t.id === id)) return;
22
+ toasts = toasts.filter((t) => t.id !== id);
23
+ emit();
24
+ },
25
+ clear() { toasts = []; emit(); },
26
+ subscribe(f: () => void) { subs.add(f); return () => { subs.delete(f); }; },
27
+ };
28
+ }
@@ -0,0 +1,51 @@
1
+ /** Pure style builders for the chat surfaces: message bubble and composer. */
2
+ import type { TextStyle, ViewStyle } from 'react-native';
3
+ import type { Theme } from './theme';
4
+
5
+ export type ChatRole = 'user' | 'assistant' | 'system';
6
+
7
+ export function bubbleStyle(theme: Theme, role: ChatRole): ViewStyle {
8
+ const base: ViewStyle = {
9
+ maxWidth: '85%',
10
+ paddingHorizontal: theme.spacing.md,
11
+ paddingVertical: theme.spacing.sm + 2,
12
+ borderRadius: theme.radii.xl,
13
+ };
14
+ if (role === 'user') return { ...base, alignSelf: 'flex-end', backgroundColor: theme.colors.primary, borderBottomRightRadius: theme.radii.sm };
15
+ if (role === 'system') return { ...base, alignSelf: 'center', backgroundColor: 'transparent', maxWidth: '95%' };
16
+ return { ...base, alignSelf: 'flex-start', backgroundColor: theme.colors.muted, borderBottomLeftRadius: theme.radii.sm };
17
+ }
18
+
19
+ export function bubbleTextColor(theme: Theme, role: ChatRole): string {
20
+ if (role === 'user') return theme.colors.primaryForeground;
21
+ if (role === 'system') return theme.colors.mutedForeground;
22
+ return theme.colors.foreground;
23
+ }
24
+
25
+ export function composerStyle(theme: Theme): ViewStyle {
26
+ return {
27
+ flexDirection: 'row',
28
+ alignItems: 'flex-end',
29
+ gap: theme.spacing.sm,
30
+ paddingHorizontal: theme.spacing.md,
31
+ paddingTop: theme.spacing.sm,
32
+ borderTopWidth: 1,
33
+ borderTopColor: theme.colors.border,
34
+ backgroundColor: theme.colors.background,
35
+ };
36
+ }
37
+
38
+ export function sendButtonStyle(theme: Theme, enabled: boolean): ViewStyle {
39
+ return {
40
+ width: 44,
41
+ height: 44,
42
+ borderRadius: theme.radii.full,
43
+ alignItems: 'center',
44
+ justifyContent: 'center',
45
+ backgroundColor: enabled ? theme.colors.primary : theme.colors.muted,
46
+ };
47
+ }
48
+
49
+ export function sendGlyphStyle(theme: Theme, enabled: boolean): TextStyle {
50
+ return { fontSize: 20, fontWeight: '700', color: enabled ? theme.colors.primaryForeground : theme.colors.mutedForeground };
51
+ }
@@ -0,0 +1,100 @@
1
+ /** Pure style builders for overlays and chrome: sheet, toast, header, swipe actions. */
2
+ import type { TextStyle, ViewStyle } from 'react-native';
3
+ import type { Theme } from './theme';
4
+
5
+ export type ToneName = 'info' | 'success' | 'error' | 'warning';
6
+
7
+ export function sheetBackdropStyle(): ViewStyle {
8
+ return { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.45)' };
9
+ }
10
+
11
+ export function sheetPanelStyle(theme: Theme, maxHeight: number, bottomInset: number): ViewStyle {
12
+ return {
13
+ maxHeight,
14
+ backgroundColor: theme.colors.background,
15
+ borderTopLeftRadius: theme.radii.xl,
16
+ borderTopRightRadius: theme.radii.xl,
17
+ paddingTop: theme.spacing.sm,
18
+ paddingHorizontal: theme.spacing.lg,
19
+ paddingBottom: bottomInset + theme.spacing.lg,
20
+ borderWidth: 1,
21
+ borderBottomWidth: 0,
22
+ borderColor: theme.colors.border,
23
+ };
24
+ }
25
+
26
+ export function sheetGrabberStyle(theme: Theme): ViewStyle {
27
+ return { alignSelf: 'center', width: 36, height: 5, borderRadius: 3, backgroundColor: theme.colors.border, marginBottom: theme.spacing.md };
28
+ }
29
+
30
+ export function toastStyle(theme: Theme, tone: ToneName): ViewStyle {
31
+ const bg: Record<ToneName, string> = {
32
+ info: theme.colors.foreground,
33
+ success: theme.colors.success,
34
+ error: theme.colors.destructive,
35
+ warning: theme.colors.warning,
36
+ };
37
+ return {
38
+ backgroundColor: bg[tone],
39
+ borderRadius: theme.radii.lg,
40
+ paddingVertical: theme.spacing.md,
41
+ paddingHorizontal: theme.spacing.lg,
42
+ shadowColor: '#000',
43
+ shadowOpacity: 0.2,
44
+ shadowRadius: 8,
45
+ shadowOffset: { width: 0, height: 2 },
46
+ elevation: 4,
47
+ };
48
+ }
49
+
50
+ export function toastTextStyle(theme: Theme, tone: ToneName): TextStyle {
51
+ const fg: Record<ToneName, string> = {
52
+ info: theme.colors.background,
53
+ success: theme.colors.successForeground,
54
+ error: theme.colors.destructiveForeground,
55
+ warning: theme.colors.warningForeground,
56
+ };
57
+ return { color: fg[tone], fontSize: theme.fontSizes.sm, fontWeight: theme.fontWeights.medium as TextStyle['fontWeight'] };
58
+ }
59
+
60
+ export function headerStyle(theme: Theme, topInset: number): ViewStyle {
61
+ return {
62
+ flexDirection: 'row',
63
+ alignItems: 'center',
64
+ gap: theme.spacing.sm,
65
+ paddingTop: topInset + theme.spacing.sm,
66
+ paddingBottom: theme.spacing.sm,
67
+ paddingHorizontal: theme.spacing.lg,
68
+ backgroundColor: theme.colors.background,
69
+ borderBottomWidth: 1,
70
+ borderBottomColor: theme.colors.border,
71
+ minHeight: 52 + topInset,
72
+ };
73
+ }
74
+
75
+ export type SwipeTone = 'primary' | 'success' | 'destructive' | 'neutral';
76
+
77
+ export function swipeActionStyle(theme: Theme, tone: SwipeTone, width: number): ViewStyle {
78
+ const bg: Record<SwipeTone, string> = {
79
+ primary: theme.colors.primary,
80
+ success: theme.colors.success,
81
+ destructive: theme.colors.destructive,
82
+ neutral: theme.colors.muted,
83
+ };
84
+ return { width, alignItems: 'center', justifyContent: 'center', backgroundColor: bg[tone] };
85
+ }
86
+
87
+ export function swipeActionTextStyle(theme: Theme, tone: SwipeTone): TextStyle {
88
+ const fg: Record<SwipeTone, string> = {
89
+ primary: theme.colors.primaryForeground,
90
+ success: theme.colors.successForeground,
91
+ destructive: theme.colors.destructiveForeground,
92
+ neutral: theme.colors.foreground,
93
+ };
94
+ return { color: fg[tone], fontSize: theme.fontSizes.sm, fontWeight: theme.fontWeights.semibold as TextStyle['fontWeight'] };
95
+ }
96
+
97
+ /** Readable red for error TEXT: the dark-scheme destructive token is a fill colour and is too dim on a dark background. */
98
+ export function errorTextColor(theme: Theme): string {
99
+ return theme.scheme === 'dark' ? 'hsl(0 90% 70%)' : theme.colors.destructive;
100
+ }
package/src/styles.ts CHANGED
@@ -467,6 +467,9 @@ export function tabItemStyle(theme: Theme): ViewStyle {
467
467
  justifyContent: 'center',
468
468
  gap: 2,
469
469
  paddingVertical: theme.spacing.xs,
470
+ // Lets a long label shrink to the slot and ellipsize instead of widening it.
471
+ minWidth: 0,
472
+ paddingHorizontal: 2,
470
473
  };
471
474
  }
472
475