panelui-native 0.30.0 → 0.32.0

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 (61) hide show
  1. package/README.md +4 -2
  2. package/lib/module/components/calendar/index.js +107 -24
  3. package/lib/module/components/calendar/index.js.map +1 -1
  4. package/lib/module/components/heatmap-chart/index.js +9 -8
  5. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  6. package/lib/module/components/map/index.js +50 -4
  7. package/lib/module/components/map/index.js.map +1 -1
  8. package/lib/module/components/plan/index.js +307 -21
  9. package/lib/module/components/plan/index.js.map +1 -1
  10. package/lib/module/components/post/index.js +992 -0
  11. package/lib/module/components/post/index.js.map +1 -0
  12. package/lib/module/components/response/index.js +296 -0
  13. package/lib/module/components/response/index.js.map +1 -0
  14. package/lib/module/components/response/markdown.js +463 -0
  15. package/lib/module/components/response/markdown.js.map +1 -0
  16. package/lib/module/components/select/index.js +97 -15
  17. package/lib/module/components/select/index.js.map +1 -1
  18. package/lib/module/components/table/index.js +632 -0
  19. package/lib/module/components/table/index.js.map +1 -0
  20. package/lib/module/icons/index.js +200 -0
  21. package/lib/module/icons/index.js.map +1 -1
  22. package/lib/module/index.js +4 -1
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/utils/date.js +129 -22
  25. package/lib/module/utils/date.js.map +1 -1
  26. package/lib/typescript/src/components/calendar/index.d.ts +12 -3
  27. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/map/index.d.ts +8 -1
  30. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/plan/index.d.ts +121 -10
  32. package/lib/typescript/src/components/plan/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/post/index.d.ts +358 -0
  34. package/lib/typescript/src/components/post/index.d.ts.map +1 -0
  35. package/lib/typescript/src/components/response/index.d.ts +91 -0
  36. package/lib/typescript/src/components/response/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/response/markdown.d.ts +88 -0
  38. package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
  39. package/lib/typescript/src/components/select/index.d.ts +27 -2
  40. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/table/index.d.ts +274 -0
  42. package/lib/typescript/src/components/table/index.d.ts.map +1 -0
  43. package/lib/typescript/src/icons/index.d.ts +40 -1
  44. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  45. package/lib/typescript/src/index.d.ts +5 -2
  46. package/lib/typescript/src/index.d.ts.map +1 -1
  47. package/lib/typescript/src/utils/date.d.ts +49 -5
  48. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/components/calendar/index.tsx +136 -23
  51. package/src/components/heatmap-chart/index.tsx +9 -9
  52. package/src/components/map/index.tsx +53 -4
  53. package/src/components/plan/index.tsx +344 -21
  54. package/src/components/post/index.tsx +1116 -0
  55. package/src/components/response/index.tsx +390 -0
  56. package/src/components/response/markdown.ts +489 -0
  57. package/src/components/select/index.tsx +132 -12
  58. package/src/components/table/index.tsx +747 -0
  59. package/src/icons/index.tsx +162 -1
  60. package/src/index.ts +60 -0
  61. package/src/utils/date.ts +146 -22
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Response — a model's answer, rendered as it arrives.
3
+ *
4
+ * An answer is markdown. It has headings and lists and fenced code in it,
5
+ * because that is what a model writes, and rendering it as one run of plain
6
+ * text throws away the structure the model went to the trouble of producing.
7
+ *
8
+ * So this reads the markdown and renders it through the library's own parts —
9
+ * `Typography` for prose, `CodeBlock` for fences, `Table` for tables. Nothing
10
+ * here draws its own type or its own colours: an answer inside a message bubble
11
+ * should look like the app it is in, not like a document viewer someone
12
+ * embedded.
13
+ *
14
+ * ```tsx
15
+ * <Response isStreaming={status === 'streaming'}>{text}</Response>
16
+ * ```
17
+ *
18
+ * ## Why it is a whole component and not a `<Text>`
19
+ *
20
+ * Because the text is still arriving, and that changes everything about how it
21
+ * has to be read. A token stream hands you every prefix of the final answer, so
22
+ * a renderer sees `**bo`, then `**bol`, then `**bold**` — three documents, two
23
+ * of which have literal asterisks in them. Render each faithfully and the
24
+ * answer flickers between styles on nearly every frame, which is worse than no
25
+ * formatting at all: the eye tracks the flicker instead of the words.
26
+ *
27
+ * `isStreaming` tells the reader to finish an unterminated construct at the end
28
+ * of the input rather than escaping it — an open fence is a code block that is
29
+ * still filling, an open `**` is bold text still being written. The rule it
30
+ * works to is that no word already on screen may disappear when the next token
31
+ * arrives; delimiters may vanish as they are recognised, words never do.
32
+ *
33
+ * ## Where the props come from
34
+ *
35
+ * With the AI SDK, `children` is the text of the assistant message's text parts
36
+ * joined together, and `isStreaming` is `status === 'streaming'`. Join the
37
+ * parts — one `Response` per part renders a heading in one component and the
38
+ * paragraph under it in another, and neither knows about the other.
39
+ */
40
+ import { memo, useMemo, type ComponentType, type ReactNode } from 'react';
41
+ import { Linking, View, type ViewProps } from 'react-native';
42
+ import { Text } from '../../primitives/text';
43
+ import { cn } from '../../utils/cn';
44
+ import { CodeBlock } from '../code-block';
45
+ import { Table } from '../table';
46
+ import { Typography } from '../typography';
47
+ import {
48
+ parseMarkdown,
49
+ type Align,
50
+ type Block,
51
+ type InlineToken,
52
+ } from './markdown';
53
+
54
+ export type { Block as ResponseBlock, InlineToken as ResponseInline } from './markdown';
55
+
56
+ /** Heading levels below h4 are h4's size; a chat answer has no use for six. */
57
+ const HEADING_TYPE = ['h2', 'h3', 'h4', 'h4', 'h4', 'h4'] as const;
58
+
59
+ /** Schemes a link may open with. Anything else is rendered but not pressable. */
60
+ const DEFAULT_LINK_PREFIXES = ['https://', 'http://', 'mailto:', 'tel:'];
61
+
62
+ export interface ResponseComponents {
63
+ /** Replaces the whole code block — for a runnable snippet, or a diff viewer. */
64
+ code?: ComponentType<{ code: string; language?: string; streaming: boolean }>;
65
+ /** Replaces an image. Nothing is rendered for one by default. */
66
+ image?: ComponentType<{ src: string; alt: string }>;
67
+ }
68
+
69
+ export interface ResponseProps extends Omit<ViewProps, 'children'> {
70
+ className?: string;
71
+ /** The markdown. */
72
+ children?: string;
73
+ /**
74
+ * Whether more is still coming. Finishes an unterminated construct at the end
75
+ * of the text instead of escaping it, so the answer does not flicker between
76
+ * styles as its delimiters arrive.
77
+ */
78
+ isStreaming?: boolean;
79
+ /** Turns off speculative completion entirely, even while streaming. */
80
+ parseIncompleteMarkdown?: boolean;
81
+ /** What a link does. Opens it with the system handler by default. */
82
+ onLinkPress?: (href: string) => void;
83
+ /**
84
+ * Schemes a link is allowed to open. A model can write any URL it likes, and
85
+ * an answer is not a trusted document — so the default list is the four that
86
+ * cannot do anything but navigate.
87
+ */
88
+ allowedLinkPrefixes?: string[];
89
+ /** Swap out how a block is drawn. */
90
+ components?: ResponseComponents;
91
+ /** Line numbers in fenced code. */
92
+ showLineNumbers?: boolean;
93
+ }
94
+
95
+ function ResponseRoot({
96
+ className,
97
+ children = '',
98
+ isStreaming = false,
99
+ parseIncompleteMarkdown = true,
100
+ onLinkPress,
101
+ allowedLinkPrefixes = DEFAULT_LINK_PREFIXES,
102
+ components,
103
+ showLineNumbers = false,
104
+ ...props
105
+ }: ResponseProps) {
106
+ const speculate = isStreaming && parseIncompleteMarkdown;
107
+ const blocks = useMemo(
108
+ () => parseMarkdown(children, speculate),
109
+ [children, speculate]
110
+ );
111
+
112
+ const context: RenderContext = {
113
+ streaming: isStreaming,
114
+ onLinkPress,
115
+ allowedLinkPrefixes,
116
+ components,
117
+ showLineNumbers,
118
+ };
119
+
120
+ return (
121
+ <View className={cn('w-full gap-3', className)} {...props}>
122
+ {blocks.map((block, index) => (
123
+ <BlockView key={index} block={block} context={context} />
124
+ ))}
125
+ </View>
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Re-renders only when the text changes.
131
+ *
132
+ * A stream re-renders its parent on every token, and everything else the parent
133
+ * hands down — the callbacks, the overrides — is usually a fresh object each
134
+ * time. Comparing the one prop that actually decides the output keeps a long
135
+ * answer from re-parsing because a sibling moved.
136
+ */
137
+ export const Response = memo(
138
+ ResponseRoot,
139
+ (previous, next) =>
140
+ previous.children === next.children &&
141
+ previous.isStreaming === next.isStreaming &&
142
+ previous.className === next.className
143
+ );
144
+ Response.displayName = 'Response';
145
+
146
+ /* -------------------------------------------------------------------------- */
147
+ /* Blocks */
148
+ /* -------------------------------------------------------------------------- */
149
+
150
+ interface RenderContext {
151
+ streaming: boolean;
152
+ onLinkPress?: (href: string) => void;
153
+ allowedLinkPrefixes: string[];
154
+ components?: ResponseComponents;
155
+ showLineNumbers: boolean;
156
+ }
157
+
158
+ function BlockView({ block, context }: { block: Block; context: RenderContext }) {
159
+ switch (block.type) {
160
+ case 'heading':
161
+ return (
162
+ <Typography.Heading type={HEADING_TYPE[block.level - 1]}>
163
+ {spans(block.inline, context)}
164
+ </Typography.Heading>
165
+ );
166
+
167
+ case 'paragraph':
168
+ return (
169
+ <Typography.Paragraph>{spans(block.inline, context)}</Typography.Paragraph>
170
+ );
171
+
172
+ case 'code': {
173
+ const Custom = context.components?.code;
174
+ if (Custom) {
175
+ return (
176
+ <Custom
177
+ code={block.code}
178
+ language={block.language}
179
+ streaming={block.open && context.streaming}
180
+ />
181
+ );
182
+ }
183
+ return (
184
+ <CodeBlock
185
+ code={block.code}
186
+ language={block.language}
187
+ showLineNumbers={context.showLineNumbers}
188
+ >
189
+ {block.language ? (
190
+ <CodeBlock.Header>
191
+ <CodeBlock.Language>{block.language}</CodeBlock.Language>
192
+ <CodeBlock.Actions>
193
+ {/*
194
+ No copy button while the fence is still open: copying half a
195
+ snippet is worse than not offering to, because it succeeds.
196
+ */}
197
+ {block.open && context.streaming ? null : <CodeBlock.CopyButton />}
198
+ </CodeBlock.Actions>
199
+ </CodeBlock.Header>
200
+ ) : null}
201
+ </CodeBlock>
202
+ );
203
+ }
204
+
205
+ case 'quote':
206
+ // The rule is drawn here rather than by `Typography.Blockquote`, which
207
+ // puts its children inside a `Text` — right for a quoted sentence, wrong
208
+ // for a quote that turns out to contain a list and a snippet. `border-s`
209
+ // so the rule moves to the other side under a right-to-left `Direction`.
210
+ return (
211
+ <View className="w-full gap-2 border-s-2 border-border ps-4">
212
+ {block.blocks.map((child, index) => (
213
+ <BlockView key={index} block={child} context={context} />
214
+ ))}
215
+ </View>
216
+ );
217
+
218
+ case 'list':
219
+ return (
220
+ <Typography.List ordered={block.ordered}>
221
+ {block.items.map((item, index) => (
222
+ <View key={index} className="gap-2">
223
+ {item.map((child, position) => (
224
+ <BlockView key={position} block={child} context={context} />
225
+ ))}
226
+ </View>
227
+ ))}
228
+ </Typography.List>
229
+ );
230
+
231
+ case 'table':
232
+ return <TableBlock block={block} context={context} />;
233
+
234
+ case 'rule':
235
+ return <View className="h-px w-full bg-border" />;
236
+ }
237
+ }
238
+
239
+ const ALIGNMENT: Record<Align, 'start' | 'center' | 'end'> = {
240
+ left: 'start',
241
+ center: 'center',
242
+ right: 'end',
243
+ };
244
+
245
+ /**
246
+ * A GFM table.
247
+ *
248
+ * Every column gets the same share of the width. A markdown table carries no
249
+ * widths, and guessing them from the longest cell makes the columns jump about
250
+ * as rows stream in — which is exactly the movement everything else here is
251
+ * built to avoid.
252
+ */
253
+ function TableBlock({
254
+ block,
255
+ context,
256
+ }: {
257
+ block: Extract<Block, { type: 'table' }>;
258
+ context: RenderContext;
259
+ }) {
260
+ const align = (index: number) => ALIGNMENT[block.align[index] ?? 'left'];
261
+
262
+ return (
263
+ <Table variant="outline" size="sm">
264
+ <Table.Header>
265
+ <Table.Row>
266
+ {block.head.map((cell, index) => (
267
+ <Table.Head key={index} align={align(index)}>
268
+ <InlineRun
269
+ tokens={cell}
270
+ context={context}
271
+ className="text-[11px] font-medium text-muted-foreground"
272
+ />
273
+ </Table.Head>
274
+ ))}
275
+ </Table.Row>
276
+ </Table.Header>
277
+ <Table.Body>
278
+ {block.rows.map((row, rowIndex) => (
279
+ <Table.Row key={rowIndex}>
280
+ {block.head.map((_unused, index) => (
281
+ <Table.Cell key={index} align={align(index)}>
282
+ <InlineRun
283
+ tokens={row[index] ?? []}
284
+ context={context}
285
+ className="text-xs text-foreground"
286
+ />
287
+ </Table.Cell>
288
+ ))}
289
+ </Table.Row>
290
+ ))}
291
+ </Table.Body>
292
+ </Table>
293
+ );
294
+ }
295
+
296
+ /* -------------------------------------------------------------------------- */
297
+ /* Inline */
298
+ /* -------------------------------------------------------------------------- */
299
+
300
+ /**
301
+ * A run of styled spans, inside a `Text` of its own.
302
+ *
303
+ * Nested `Text` rather than a row of views, because only nested text wraps as
304
+ * one paragraph — a bolded word in a view would break the line before it and
305
+ * leave a gap where the sentence should have continued.
306
+ *
307
+ * The wrapper is not optional. Half these spans are bare strings, and a bare
308
+ * string is only legal inside a `Text`: dropped into a `Table.Cell` — which is
309
+ * a view — React Native refuses to render it at all.
310
+ */
311
+ function InlineRun({
312
+ tokens,
313
+ context,
314
+ className,
315
+ }: {
316
+ tokens: InlineToken[];
317
+ context: RenderContext;
318
+ className?: string;
319
+ }) {
320
+ return <Text className={className}>{spans(tokens, context)}</Text>;
321
+ }
322
+
323
+ function spans(tokens: InlineToken[], context: RenderContext): ReactNode {
324
+ return tokens.map((token, index) => {
325
+ if (token.kind === 'image') {
326
+ const Custom = context.components?.image;
327
+ // Nothing by default. An image in an answer is a URL the model wrote, and
328
+ // fetching it unasked is a request to a host nobody chose.
329
+ return Custom ? <Custom key={index} src={token.href ?? ''} alt={token.value} /> : null;
330
+ }
331
+
332
+ if (token.kind === 'code') {
333
+ // A `Text`, not `Typography.Code` — that one is a view around a text, and
334
+ // a view inside a paragraph breaks the line before it and leaves a gap
335
+ // where the sentence should have carried on. A background and a
336
+ // monospace face are what make it read as code; they survive nesting,
337
+ // and padding does not.
338
+ //
339
+ // No relative font size either. There is no `em` here to be relative to:
340
+ // a value in one sends the style resolver looking for a parent size that
341
+ // is itself expressed in `em`, and it does not come back. The inherited
342
+ // size is the right one anyway — inline code is part of its sentence.
343
+ return (
344
+ <Text key={index} className={cn('bg-muted font-mono', marks(token))}>
345
+ {token.value}
346
+ </Text>
347
+ );
348
+ }
349
+
350
+ if (token.kind === 'link') {
351
+ const href = token.href ?? '';
352
+ const openable = context.allowedLinkPrefixes.some((prefix) => href.startsWith(prefix));
353
+ return (
354
+ <Text
355
+ key={index}
356
+ className={cn('text-primary underline', marks(token))}
357
+ onPress={
358
+ openable
359
+ ? () => (context.onLinkPress ?? defaultOpen)(href)
360
+ : undefined
361
+ }
362
+ >
363
+ {token.value}
364
+ </Text>
365
+ );
366
+ }
367
+
368
+ if (!token.bold && !token.italic && !token.strike) return token.value;
369
+
370
+ return (
371
+ <Text key={index} className={marks(token)}>
372
+ {token.value}
373
+ </Text>
374
+ );
375
+ });
376
+ }
377
+
378
+ function marks(token: InlineToken): string {
379
+ return cn(
380
+ token.bold && 'font-semibold',
381
+ token.italic && 'italic',
382
+ token.strike && 'line-through'
383
+ );
384
+ }
385
+
386
+ function defaultOpen(href: string) {
387
+ // Fire and forget: a link the platform declines to open is not worth an
388
+ // unhandled rejection, and there is nothing useful to do about one.
389
+ void Linking.openURL(href).catch(() => {});
390
+ }