panelui-native 0.28.1 → 0.30.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.
- package/README.md +18 -1
- package/lib/module/components/calendar/index.js +350 -115
- package/lib/module/components/calendar/index.js.map +1 -1
- package/lib/module/components/code-block/highlight.js +363 -0
- package/lib/module/components/code-block/highlight.js.map +1 -0
- package/lib/module/components/code-block/index.js +313 -0
- package/lib/module/components/code-block/index.js.map +1 -0
- package/lib/module/components/date-picker/index.js +8 -1
- package/lib/module/components/date-picker/index.js.map +1 -1
- package/lib/module/components/menu/index.js +714 -0
- package/lib/module/components/menu/index.js.map +1 -0
- package/lib/module/components/plan/index.js +296 -0
- package/lib/module/components/plan/index.js.map +1 -0
- package/lib/module/components/popover/index.js +35 -4
- package/lib/module/components/popover/index.js.map +1 -1
- package/lib/module/components/radio-group/index.js +13 -8
- package/lib/module/components/radio-group/index.js.map +1 -1
- package/lib/module/components/reasoning/index.js +244 -0
- package/lib/module/components/reasoning/index.js.map +1 -0
- package/lib/module/components/sources/index.js +221 -0
- package/lib/module/components/sources/index.js.map +1 -0
- package/lib/module/components/task/index.js +300 -0
- package/lib/module/components/task/index.js.map +1 -0
- package/lib/module/components/textarea/index.js +295 -0
- package/lib/module/components/textarea/index.js.map +1 -0
- package/lib/module/components/tooltip/index.js +179 -23
- package/lib/module/components/tooltip/index.js.map +1 -1
- package/lib/module/icons/index.js +139 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +8 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/primitives/collapse.js +112 -0
- package/lib/module/primitives/collapse.js.map +1 -0
- package/lib/typescript/src/components/calendar/index.d.ts +68 -3
- package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
- package/lib/typescript/src/components/code-block/highlight.d.ts +39 -0
- package/lib/typescript/src/components/code-block/highlight.d.ts.map +1 -0
- package/lib/typescript/src/components/code-block/index.d.ts +113 -0
- package/lib/typescript/src/components/code-block/index.d.ts.map +1 -0
- package/lib/typescript/src/components/date-picker/index.d.ts +7 -1
- package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/menu/index.d.ts +261 -0
- package/lib/typescript/src/components/menu/index.d.ts.map +1 -0
- package/lib/typescript/src/components/plan/index.d.ts +127 -0
- package/lib/typescript/src/components/plan/index.d.ts.map +1 -0
- package/lib/typescript/src/components/popover/index.d.ts +35 -1
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
- package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/reasoning/index.d.ts +97 -0
- package/lib/typescript/src/components/reasoning/index.d.ts.map +1 -0
- package/lib/typescript/src/components/sources/index.d.ts +85 -0
- package/lib/typescript/src/components/sources/index.d.ts.map +1 -0
- package/lib/typescript/src/components/task/index.d.ts +103 -0
- package/lib/typescript/src/components/task/index.d.ts.map +1 -0
- package/lib/typescript/src/components/textarea/index.d.ts +175 -0
- package/lib/typescript/src/components/textarea/index.d.ts.map +1 -0
- package/lib/typescript/src/components/tooltip/index.d.ts +63 -8
- package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -1
- package/lib/typescript/src/icons/index.d.ts +14 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +10 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/collapse.d.ts +42 -0
- package/lib/typescript/src/primitives/collapse.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/calendar/index.tsx +389 -122
- package/src/components/code-block/highlight.ts +278 -0
- package/src/components/code-block/index.tsx +341 -0
- package/src/components/date-picker/index.tsx +19 -1
- package/src/components/menu/index.tsx +812 -0
- package/src/components/plan/index.tsx +325 -0
- package/src/components/popover/index.tsx +69 -2
- package/src/components/radio-group/index.tsx +11 -2
- package/src/components/reasoning/index.tsx +297 -0
- package/src/components/sources/index.tsx +278 -0
- package/src/components/task/index.tsx +329 -0
- package/src/components/textarea/index.tsx +381 -0
- package/src/components/tooltip/index.tsx +203 -20
- package/src/icons/index.tsx +102 -0
- package/src/index.ts +71 -0
- package/src/primitives/collapse.tsx +128 -0
- package/theme.css +112 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small syntax highlighter, sized for a phone.
|
|
3
|
+
*
|
|
4
|
+
* The web answer to this problem is a real tokenizer with a grammar per
|
|
5
|
+
* language, which is megabytes of WASM and a worker. That is the wrong shape
|
|
6
|
+
* here twice over: a chat renders short fragments rather than files, and the
|
|
7
|
+
* fragments arrive a token at a time, so whatever runs has to run again on
|
|
8
|
+
* every frame of a stream. This trades exactness for a single pass of regex
|
|
9
|
+
* that costs nothing and never blocks.
|
|
10
|
+
*
|
|
11
|
+
* What it therefore does *not* do: nested languages, template-literal
|
|
12
|
+
* interpolation, or anything requiring a parser to know it. What it does do is
|
|
13
|
+
* make a keyword look like a keyword, which is the whole of the value in a
|
|
14
|
+
* twelve-line snippet.
|
|
15
|
+
*
|
|
16
|
+
* Anything it does not recognise is returned as one plain token per line, so
|
|
17
|
+
* an unknown language renders as clean monospace rather than as a guess.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The kinds a token can be. Each maps to one theme colour. */
|
|
21
|
+
export type TokenKind =
|
|
22
|
+
| 'plain'
|
|
23
|
+
| 'keyword'
|
|
24
|
+
| 'string'
|
|
25
|
+
| 'number'
|
|
26
|
+
| 'comment'
|
|
27
|
+
| 'function'
|
|
28
|
+
| 'property'
|
|
29
|
+
| 'punctuation'
|
|
30
|
+
| 'inserted'
|
|
31
|
+
| 'deleted';
|
|
32
|
+
|
|
33
|
+
export interface Token {
|
|
34
|
+
text: string;
|
|
35
|
+
kind: TokenKind;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The languages that get more than plain monospace. */
|
|
39
|
+
export type CodeLanguage =
|
|
40
|
+
| 'ts'
|
|
41
|
+
| 'tsx'
|
|
42
|
+
| 'js'
|
|
43
|
+
| 'jsx'
|
|
44
|
+
| 'json'
|
|
45
|
+
| 'bash'
|
|
46
|
+
| 'python'
|
|
47
|
+
| 'css'
|
|
48
|
+
| 'html'
|
|
49
|
+
| 'sql'
|
|
50
|
+
| 'markdown'
|
|
51
|
+
| 'diff'
|
|
52
|
+
| 'text';
|
|
53
|
+
|
|
54
|
+
/** Spellings a caller is likely to pass, mapped onto the ones handled here. */
|
|
55
|
+
const ALIASES: Record<string, CodeLanguage> = {
|
|
56
|
+
typescript: 'ts',
|
|
57
|
+
ts: 'ts',
|
|
58
|
+
tsx: 'tsx',
|
|
59
|
+
javascript: 'js',
|
|
60
|
+
js: 'js',
|
|
61
|
+
jsx: 'jsx',
|
|
62
|
+
mjs: 'js',
|
|
63
|
+
cjs: 'js',
|
|
64
|
+
json: 'json',
|
|
65
|
+
json5: 'json',
|
|
66
|
+
bash: 'bash',
|
|
67
|
+
sh: 'bash',
|
|
68
|
+
shell: 'bash',
|
|
69
|
+
zsh: 'bash',
|
|
70
|
+
console: 'bash',
|
|
71
|
+
python: 'python',
|
|
72
|
+
py: 'python',
|
|
73
|
+
css: 'css',
|
|
74
|
+
scss: 'css',
|
|
75
|
+
html: 'html',
|
|
76
|
+
xml: 'html',
|
|
77
|
+
svg: 'html',
|
|
78
|
+
sql: 'sql',
|
|
79
|
+
markdown: 'markdown',
|
|
80
|
+
md: 'markdown',
|
|
81
|
+
mdx: 'markdown',
|
|
82
|
+
diff: 'diff',
|
|
83
|
+
patch: 'diff',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Resolves a caller's language string. Unknown spellings become `text`. */
|
|
87
|
+
export function resolveLanguage(language: string | undefined): CodeLanguage {
|
|
88
|
+
if (!language) return 'text';
|
|
89
|
+
return ALIASES[language.toLowerCase()] ?? 'text';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const JS_KEYWORDS =
|
|
93
|
+
'as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|is|keyof|let|new|of|private|protected|public|readonly|return|satisfies|set|static|super|switch|this|throw|try|type|typeof|var|void|while|yield';
|
|
94
|
+
const JS_LITERALS = 'true|false|null|undefined|NaN|Infinity';
|
|
95
|
+
|
|
96
|
+
const PYTHON_KEYWORDS =
|
|
97
|
+
'and|as|assert|async|await|break|class|continue|def|del|elif|else|except|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|raise|return|try|while|with|yield';
|
|
98
|
+
const PYTHON_LITERALS = 'True|False|None|self';
|
|
99
|
+
|
|
100
|
+
const SQL_KEYWORDS =
|
|
101
|
+
'ADD|ALL|ALTER|AND|AS|ASC|BETWEEN|BY|CASE|COLUMN|CREATE|CROSS|DELETE|DESC|DISTINCT|DROP|ELSE|END|EXISTS|FROM|FULL|GROUP|HAVING|IN|INDEX|INNER|INSERT|INTO|IS|JOIN|LEFT|LIKE|LIMIT|NOT|NULL|OFFSET|ON|OR|ORDER|OUTER|PRIMARY|RIGHT|SELECT|SET|TABLE|THEN|UNION|UPDATE|VALUES|WHEN|WHERE|WITH';
|
|
102
|
+
|
|
103
|
+
const BASH_KEYWORDS =
|
|
104
|
+
'case|do|done|elif|else|esac|export|fi|for|function|if|in|local|return|source|then|until|while';
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* One rule: a pattern, and the kind it produces.
|
|
108
|
+
*
|
|
109
|
+
* Order is the whole design. Comments and strings come first so that a keyword
|
|
110
|
+
* inside either is left alone — the single most visible failure a highlighter
|
|
111
|
+
* this size can have.
|
|
112
|
+
*/
|
|
113
|
+
interface Rule {
|
|
114
|
+
pattern: RegExp;
|
|
115
|
+
kind: TokenKind;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function wordsOf(list: string): RegExp {
|
|
119
|
+
return new RegExp(`^(?:${list})\\b`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const RULES: Record<CodeLanguage, Rule[]> = {
|
|
123
|
+
ts: jsRules(),
|
|
124
|
+
tsx: jsRules(),
|
|
125
|
+
js: jsRules(),
|
|
126
|
+
jsx: jsRules(),
|
|
127
|
+
json: [
|
|
128
|
+
{ pattern: /^"(?:[^"\\]|\\.)*"\s*(?=:)/, kind: 'property' },
|
|
129
|
+
{ pattern: /^"(?:[^"\\]|\\.)*"/, kind: 'string' },
|
|
130
|
+
{ pattern: /^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/i, kind: 'number' },
|
|
131
|
+
{ pattern: /^(?:true|false|null)\b/, kind: 'keyword' },
|
|
132
|
+
{ pattern: /^[{}[\],:]/, kind: 'punctuation' },
|
|
133
|
+
],
|
|
134
|
+
bash: [
|
|
135
|
+
{ pattern: /^#.*/, kind: 'comment' },
|
|
136
|
+
{ pattern: /^"(?:[^"\\]|\\.)*"/, kind: 'string' },
|
|
137
|
+
{ pattern: /^'(?:[^'\\]|\\.)*'/, kind: 'string' },
|
|
138
|
+
{ pattern: /^\$\{?[A-Za-z_][\w]*\}?/, kind: 'property' },
|
|
139
|
+
{ pattern: wordsOf(BASH_KEYWORDS), kind: 'keyword' },
|
|
140
|
+
{ pattern: /^-{1,2}[A-Za-z][\w-]*/, kind: 'property' },
|
|
141
|
+
{ pattern: /^[|&;<>()$]/, kind: 'punctuation' },
|
|
142
|
+
],
|
|
143
|
+
python: [
|
|
144
|
+
{ pattern: /^#.*/, kind: 'comment' },
|
|
145
|
+
{ pattern: /^[rbf]?"""[\s\S]*?(?:"""|$)/, kind: 'string' },
|
|
146
|
+
{ pattern: /^[rbf]?"(?:[^"\\]|\\.)*"/, kind: 'string' },
|
|
147
|
+
{ pattern: /^[rbf]?'(?:[^'\\]|\\.)*'/, kind: 'string' },
|
|
148
|
+
{ pattern: wordsOf(PYTHON_KEYWORDS), kind: 'keyword' },
|
|
149
|
+
{ pattern: wordsOf(PYTHON_LITERALS), kind: 'keyword' },
|
|
150
|
+
{ pattern: /^[A-Za-z_]\w*(?=\s*\()/, kind: 'function' },
|
|
151
|
+
{ pattern: /^@[A-Za-z_]\w*/, kind: 'function' },
|
|
152
|
+
{ pattern: /^\d[\d_]*(?:\.\d+)?/, kind: 'number' },
|
|
153
|
+
{ pattern: /^[{}[\]().,:;]/, kind: 'punctuation' },
|
|
154
|
+
],
|
|
155
|
+
css: [
|
|
156
|
+
{ pattern: /^\/\*[\s\S]*?(?:\*\/|$)/, kind: 'comment' },
|
|
157
|
+
{ pattern: /^"(?:[^"\\]|\\.)*"/, kind: 'string' },
|
|
158
|
+
{ pattern: /^'(?:[^'\\]|\\.)*'/, kind: 'string' },
|
|
159
|
+
{ pattern: /^--[\w-]+/, kind: 'property' },
|
|
160
|
+
{ pattern: /^@[\w-]+/, kind: 'keyword' },
|
|
161
|
+
{ pattern: /^[.#][\w-]+/, kind: 'function' },
|
|
162
|
+
{ pattern: /^[a-z-]+(?=\s*:)/i, kind: 'property' },
|
|
163
|
+
{ pattern: /^-?\d*\.?\d+(?:px|rem|em|%|vh|vw|s|ms|deg)?/, kind: 'number' },
|
|
164
|
+
{ pattern: /^[{}();:,]/, kind: 'punctuation' },
|
|
165
|
+
],
|
|
166
|
+
html: [
|
|
167
|
+
{ pattern: /^<!--[\s\S]*?(?:-->|$)/, kind: 'comment' },
|
|
168
|
+
{ pattern: /^"(?:[^"\\]|\\.)*"/, kind: 'string' },
|
|
169
|
+
{ pattern: /^'(?:[^'\\]|\\.)*'/, kind: 'string' },
|
|
170
|
+
{ pattern: /^<\/?[\w-]+/, kind: 'keyword' },
|
|
171
|
+
{ pattern: /^[\w-]+(?==)/, kind: 'property' },
|
|
172
|
+
{ pattern: /^\/?>/, kind: 'keyword' },
|
|
173
|
+
{ pattern: /^[=]/, kind: 'punctuation' },
|
|
174
|
+
],
|
|
175
|
+
sql: [
|
|
176
|
+
{ pattern: /^--.*/, kind: 'comment' },
|
|
177
|
+
{ pattern: /^'(?:[^'\\]|\\.)*'/, kind: 'string' },
|
|
178
|
+
{ pattern: new RegExp(`^(?:${SQL_KEYWORDS})\\b`, 'i'), kind: 'keyword' },
|
|
179
|
+
{ pattern: /^\d+(?:\.\d+)?/, kind: 'number' },
|
|
180
|
+
{ pattern: /^[(),;*.]/, kind: 'punctuation' },
|
|
181
|
+
],
|
|
182
|
+
markdown: [
|
|
183
|
+
{ pattern: /^#{1,6}\s.*/, kind: 'keyword' },
|
|
184
|
+
{ pattern: /^`[^`]*`/, kind: 'string' },
|
|
185
|
+
{ pattern: /^\*\*[^*]+\*\*/, kind: 'function' },
|
|
186
|
+
{ pattern: /^\[[^\]]*\]\([^)]*\)/, kind: 'property' },
|
|
187
|
+
{ pattern: /^[>-]\s/, kind: 'punctuation' },
|
|
188
|
+
],
|
|
189
|
+
diff: [],
|
|
190
|
+
text: [],
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
function jsRules(): Rule[] {
|
|
194
|
+
return [
|
|
195
|
+
{ pattern: /^\/\/.*/, kind: 'comment' },
|
|
196
|
+
{ pattern: /^\/\*[\s\S]*?(?:\*\/|$)/, kind: 'comment' },
|
|
197
|
+
{ pattern: /^`(?:[^`\\]|\\.)*`/, kind: 'string' },
|
|
198
|
+
{ pattern: /^"(?:[^"\\]|\\.)*"/, kind: 'string' },
|
|
199
|
+
{ pattern: /^'(?:[^'\\]|\\.)*'/, kind: 'string' },
|
|
200
|
+
{ pattern: wordsOf(JS_KEYWORDS), kind: 'keyword' },
|
|
201
|
+
{ pattern: wordsOf(JS_LITERALS), kind: 'keyword' },
|
|
202
|
+
// A name followed by a paren reads as a call whether or not it is one,
|
|
203
|
+
// which is exactly the reading a highlighter is meant to support.
|
|
204
|
+
{ pattern: /^[A-Za-z_$][\w$]*(?=\s*\()/, kind: 'function' },
|
|
205
|
+
{ pattern: /^0[xb][\da-f]+|^\d[\d_]*(?:\.\d+)?(?:e[+-]?\d+)?/i, kind: 'number' },
|
|
206
|
+
{ pattern: /^[{}[\]().,;:?!<>=+\-*/%&|^~]/, kind: 'punctuation' },
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A diff is coloured by line, not by token — the leading `+` or `-` says
|
|
212
|
+
* everything, and highlighting the code inside as well fights with it.
|
|
213
|
+
*/
|
|
214
|
+
function tokenizeDiffLine(line: string): Token[] {
|
|
215
|
+
if (line.startsWith('+')) return [{ text: line, kind: 'inserted' }];
|
|
216
|
+
if (line.startsWith('-')) return [{ text: line, kind: 'deleted' }];
|
|
217
|
+
if (line.startsWith('@@') || line.startsWith('diff ')) {
|
|
218
|
+
return [{ text: line, kind: 'comment' }];
|
|
219
|
+
}
|
|
220
|
+
return [{ text: line, kind: 'plain' }];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Runs the rules over one line, longest-first from wherever it has got to. */
|
|
224
|
+
function tokenizeLine(line: string, rules: Rule[]): Token[] {
|
|
225
|
+
if (rules.length === 0) return line ? [{ text: line, kind: 'plain' }] : [];
|
|
226
|
+
|
|
227
|
+
const tokens: Token[] = [];
|
|
228
|
+
let rest = line;
|
|
229
|
+
let plain = '';
|
|
230
|
+
|
|
231
|
+
while (rest.length > 0) {
|
|
232
|
+
let matched: { text: string; kind: TokenKind } | null = null;
|
|
233
|
+
|
|
234
|
+
for (const rule of rules) {
|
|
235
|
+
const found = rule.pattern.exec(rest);
|
|
236
|
+
if (found && found[0].length > 0) {
|
|
237
|
+
matched = { text: found[0], kind: rule.kind };
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (matched) {
|
|
243
|
+
// Whatever was skipped over to get here is one plain run, not one plain
|
|
244
|
+
// token per character — a token per character is a view per character.
|
|
245
|
+
if (plain) {
|
|
246
|
+
tokens.push({ text: plain, kind: 'plain' });
|
|
247
|
+
plain = '';
|
|
248
|
+
}
|
|
249
|
+
tokens.push(matched);
|
|
250
|
+
rest = rest.slice(matched.text.length);
|
|
251
|
+
} else {
|
|
252
|
+
plain += rest[0];
|
|
253
|
+
rest = rest.slice(1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (plain) tokens.push({ text: plain, kind: 'plain' });
|
|
258
|
+
return tokens;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Splits code into lines of tokens.
|
|
263
|
+
*
|
|
264
|
+
* Line by line, so a stream that has just gained a character only makes the
|
|
265
|
+
* work grow with the code and never with time. The cost of that choice is that
|
|
266
|
+
* a block comment spanning several lines is only coloured on its first — an
|
|
267
|
+
* acceptable trade for a snippet, and the reason this is not sold as a
|
|
268
|
+
* highlighter for a file.
|
|
269
|
+
*/
|
|
270
|
+
export function highlight(code: string, language: string | undefined): Token[][] {
|
|
271
|
+
const resolved = resolveLanguage(language);
|
|
272
|
+
const lines = code.split('\n');
|
|
273
|
+
|
|
274
|
+
if (resolved === 'diff') return lines.map(tokenizeDiffLine);
|
|
275
|
+
|
|
276
|
+
const rules = RULES[resolved];
|
|
277
|
+
return lines.map((line) => tokenizeLine(line, rules));
|
|
278
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeBlock — a fenced snippet, syntax-coloured and scrolled sideways.
|
|
3
|
+
*
|
|
4
|
+
* Code does not wrap. Wrapping a line of code puts the continuation under the
|
|
5
|
+
* indentation and the two stop meaning different things, so the body scrolls
|
|
6
|
+
* horizontally instead — which on a phone is the only honest way to show a
|
|
7
|
+
* ninety-column line.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* <CodeBlock code={code} language="tsx">
|
|
11
|
+
* <CodeBlock.Header>
|
|
12
|
+
* <CodeBlock.Filename>calendar.tsx</CodeBlock.Filename>
|
|
13
|
+
* <CodeBlock.CopyButton />
|
|
14
|
+
* </CodeBlock.Header>
|
|
15
|
+
* </CodeBlock>
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* ## Colouring
|
|
19
|
+
*
|
|
20
|
+
* The highlighter is a single pass of regex, shipped in the component, over the
|
|
21
|
+
* languages a chat actually streams. There is no grammar and no worker, because
|
|
22
|
+
* the alternative costs megabytes and has to run again on every frame of a
|
|
23
|
+
* stream. It gets keywords, strings, comments and numbers right in a twelve-line
|
|
24
|
+
* snippet and does not pretend to more; an unrecognised language renders as
|
|
25
|
+
* clean monospace rather than as a guess. Colours come from the theme's
|
|
26
|
+
* `--color-code-*` tokens, so a snippet follows light and dark like everything
|
|
27
|
+
* else.
|
|
28
|
+
*
|
|
29
|
+
* ## Where the props come from
|
|
30
|
+
*
|
|
31
|
+
* With the AI SDK, `code` and `language` are what you pull out of a fenced
|
|
32
|
+
* block in a text part, or the fields of a tool call that wrote a file.
|
|
33
|
+
*/
|
|
34
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
35
|
+
import { ScrollView, View, type ViewProps } from 'react-native';
|
|
36
|
+
import { tv } from 'tailwind-variants';
|
|
37
|
+
import { useCSSVariable } from 'uniwind';
|
|
38
|
+
import { useCopyToClipboard } from '../../hooks/use-copy-to-clipboard';
|
|
39
|
+
import { CheckIcon, CopyIcon } from '../../icons';
|
|
40
|
+
import { Text } from '../../primitives/text';
|
|
41
|
+
import { cn } from '../../utils/cn';
|
|
42
|
+
import { AnimatedPressable } from '../../primitives/animated-pressable';
|
|
43
|
+
import { highlight, resolveLanguage, type Token, type TokenKind } from './highlight';
|
|
44
|
+
|
|
45
|
+
const codeBlockVariants = tv({
|
|
46
|
+
slots: {
|
|
47
|
+
root: 'w-full overflow-hidden rounded-2xl border border-border bg-muted/40',
|
|
48
|
+
header: 'flex-row items-center justify-between gap-2 border-b border-border px-3 py-2',
|
|
49
|
+
filename: 'font-mono text-xs text-muted-foreground',
|
|
50
|
+
language: 'text-xs uppercase tracking-wide text-muted-foreground',
|
|
51
|
+
actions: 'flex-row items-center gap-1',
|
|
52
|
+
action: 'h-7 w-7 items-center justify-center rounded-lg active:bg-accent',
|
|
53
|
+
body: 'p-3',
|
|
54
|
+
line: 'flex-row',
|
|
55
|
+
/*
|
|
56
|
+
* `leading-*` on the row rather than on each token: the tokens of a line
|
|
57
|
+
* are separate Text nodes, and a line height set on some of them and not
|
|
58
|
+
* others makes the baseline jog wherever a keyword ends.
|
|
59
|
+
*/
|
|
60
|
+
gutter: 'w-8 pe-3 text-end font-mono text-xs text-muted-foreground/50',
|
|
61
|
+
code: 'font-mono text-xs leading-5',
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
interface CodeBlockContextValue {
|
|
66
|
+
code: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const CodeBlockContext = createContext<CodeBlockContextValue | null>(null);
|
|
70
|
+
|
|
71
|
+
function useCodeBlock(component: string): CodeBlockContextValue {
|
|
72
|
+
const context = useContext(CodeBlockContext);
|
|
73
|
+
if (!context) {
|
|
74
|
+
throw new Error(`${component} must be used within a <CodeBlock>`);
|
|
75
|
+
}
|
|
76
|
+
return context;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Which theme token paints each kind of token. */
|
|
80
|
+
const TOKEN_COLORS: Record<TokenKind, string> = {
|
|
81
|
+
plain: '--color-foreground',
|
|
82
|
+
keyword: '--color-code-keyword',
|
|
83
|
+
string: '--color-code-string',
|
|
84
|
+
number: '--color-code-number',
|
|
85
|
+
comment: '--color-code-comment',
|
|
86
|
+
function: '--color-code-function',
|
|
87
|
+
property: '--color-code-property',
|
|
88
|
+
punctuation: '--color-code-punctuation',
|
|
89
|
+
inserted: '--color-code-inserted',
|
|
90
|
+
deleted: '--color-code-deleted',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The palette, resolved once for the whole block.
|
|
95
|
+
*
|
|
96
|
+
* `useCSSVariable` is a hook, so it cannot be called per token — and a snippet
|
|
97
|
+
* has hundreds. Ten lookups at the top and a lookup table underneath is the
|
|
98
|
+
* same answer at a fraction of the cost.
|
|
99
|
+
*/
|
|
100
|
+
function useTokenColors(): Record<TokenKind, string | undefined> {
|
|
101
|
+
const plain = useCSSVariable(TOKEN_COLORS.plain);
|
|
102
|
+
const keyword = useCSSVariable(TOKEN_COLORS.keyword);
|
|
103
|
+
const string = useCSSVariable(TOKEN_COLORS.string);
|
|
104
|
+
const number = useCSSVariable(TOKEN_COLORS.number);
|
|
105
|
+
const comment = useCSSVariable(TOKEN_COLORS.comment);
|
|
106
|
+
const fn = useCSSVariable(TOKEN_COLORS.function);
|
|
107
|
+
const property = useCSSVariable(TOKEN_COLORS.property);
|
|
108
|
+
const punctuation = useCSSVariable(TOKEN_COLORS.punctuation);
|
|
109
|
+
const inserted = useCSSVariable(TOKEN_COLORS.inserted);
|
|
110
|
+
const deleted = useCSSVariable(TOKEN_COLORS.deleted);
|
|
111
|
+
|
|
112
|
+
const only = (value: unknown) => (typeof value === 'string' ? value : undefined);
|
|
113
|
+
|
|
114
|
+
return useMemo(
|
|
115
|
+
() => ({
|
|
116
|
+
plain: only(plain),
|
|
117
|
+
keyword: only(keyword),
|
|
118
|
+
string: only(string),
|
|
119
|
+
number: only(number),
|
|
120
|
+
comment: only(comment),
|
|
121
|
+
function: only(fn),
|
|
122
|
+
property: only(property),
|
|
123
|
+
punctuation: only(punctuation),
|
|
124
|
+
inserted: only(inserted),
|
|
125
|
+
deleted: only(deleted),
|
|
126
|
+
}),
|
|
127
|
+
[plain, keyword, string, number, comment, fn, property, punctuation, inserted, deleted]
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface CodeBlockProps extends Omit<ViewProps, 'children'> {
|
|
132
|
+
className?: string;
|
|
133
|
+
/** The snippet. */
|
|
134
|
+
code: string;
|
|
135
|
+
/**
|
|
136
|
+
* What it is written in. `ts`, `tsx`, `js`, `jsx`, `json`, `bash`, `python`,
|
|
137
|
+
* `css`, `html`, `sql`, `markdown` and `diff` are coloured, along with the
|
|
138
|
+
* usual spellings of each; anything else renders as plain monospace.
|
|
139
|
+
*/
|
|
140
|
+
language?: string;
|
|
141
|
+
/** Number the lines in a gutter down the leading edge. */
|
|
142
|
+
showLineNumbers?: boolean;
|
|
143
|
+
/** A header, and anything else that goes above the code. */
|
|
144
|
+
children?: ReactNode;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function CodeBlockRoot({
|
|
148
|
+
className,
|
|
149
|
+
code,
|
|
150
|
+
language,
|
|
151
|
+
showLineNumbers = false,
|
|
152
|
+
children,
|
|
153
|
+
...props
|
|
154
|
+
}: CodeBlockProps) {
|
|
155
|
+
const slots = codeBlockVariants();
|
|
156
|
+
const colors = useTokenColors();
|
|
157
|
+
const lines = useMemo(() => highlight(code, language), [code, language]);
|
|
158
|
+
const context = useMemo(() => ({ code }), [code]);
|
|
159
|
+
const gutterWidth = String(lines.length).length;
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<CodeBlockContext.Provider value={context}>
|
|
163
|
+
<View {...props} className={cn(slots.root(), className)}>
|
|
164
|
+
{children}
|
|
165
|
+
{/*
|
|
166
|
+
Horizontal only. The rows are laid out at their natural width inside
|
|
167
|
+
it, so the longest line sets the scrollable width and every row shares
|
|
168
|
+
it — otherwise each row would scroll to its own end and the gutter
|
|
169
|
+
would drift away from its code.
|
|
170
|
+
*/}
|
|
171
|
+
<ScrollView
|
|
172
|
+
horizontal
|
|
173
|
+
bounces={false}
|
|
174
|
+
showsHorizontalScrollIndicator={false}
|
|
175
|
+
contentContainerClassName={slots.body()}
|
|
176
|
+
>
|
|
177
|
+
<View>
|
|
178
|
+
{lines.map((tokens, index) => (
|
|
179
|
+
<View key={index} className={slots.line()}>
|
|
180
|
+
{showLineNumbers ? (
|
|
181
|
+
<Text
|
|
182
|
+
className={slots.gutter()}
|
|
183
|
+
style={{ width: 16 + gutterWidth * 7 }}
|
|
184
|
+
>
|
|
185
|
+
{index + 1}
|
|
186
|
+
</Text>
|
|
187
|
+
) : null}
|
|
188
|
+
<CodeLine tokens={tokens} colors={colors} className={slots.code()} />
|
|
189
|
+
</View>
|
|
190
|
+
))}
|
|
191
|
+
</View>
|
|
192
|
+
</ScrollView>
|
|
193
|
+
</View>
|
|
194
|
+
</CodeBlockContext.Provider>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
CodeBlockRoot.displayName = 'CodeBlock';
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* One line, as a single Text with a run of coloured children.
|
|
201
|
+
*
|
|
202
|
+
* Nested Text rather than a row of Views: only inside one Text do the runs
|
|
203
|
+
* share a baseline and keep their spaces. A row of Views gives every token its
|
|
204
|
+
* own box and the line comes apart at the seams.
|
|
205
|
+
*/
|
|
206
|
+
function CodeLine({
|
|
207
|
+
tokens,
|
|
208
|
+
colors,
|
|
209
|
+
className,
|
|
210
|
+
}: {
|
|
211
|
+
tokens: Token[];
|
|
212
|
+
colors: Record<TokenKind, string | undefined>;
|
|
213
|
+
className: string;
|
|
214
|
+
}) {
|
|
215
|
+
return (
|
|
216
|
+
<Text className={className}>
|
|
217
|
+
{/* An empty line still needs a glyph's worth of height. */}
|
|
218
|
+
{tokens.length === 0 ? ' ' : null}
|
|
219
|
+
{tokens.map((token, index) => (
|
|
220
|
+
<Text key={index} className={className} style={{ color: colors[token.kind] }}>
|
|
221
|
+
{token.text}
|
|
222
|
+
</Text>
|
|
223
|
+
))}
|
|
224
|
+
</Text>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface CodeBlockHeaderProps extends ViewProps {
|
|
229
|
+
className?: string;
|
|
230
|
+
children?: ReactNode;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The bar above the code: what the file is, and what can be done with it. */
|
|
234
|
+
function CodeBlockHeader({ className, children, ...props }: CodeBlockHeaderProps) {
|
|
235
|
+
const { header } = codeBlockVariants();
|
|
236
|
+
return (
|
|
237
|
+
<View {...props} className={cn(header(), className)}>
|
|
238
|
+
{children}
|
|
239
|
+
</View>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface CodeBlockFilenameProps extends ViewProps {
|
|
244
|
+
className?: string;
|
|
245
|
+
children?: ReactNode;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function CodeBlockFilename({ className, children, ...props }: CodeBlockFilenameProps) {
|
|
249
|
+
const { filename } = codeBlockVariants();
|
|
250
|
+
return (
|
|
251
|
+
<Text numberOfLines={1} className={cn(filename(), className)} {...props}>
|
|
252
|
+
{children}
|
|
253
|
+
</Text>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface CodeBlockLanguageProps extends ViewProps {
|
|
258
|
+
className?: string;
|
|
259
|
+
children?: ReactNode;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The language, for a block with no filename to name it by. */
|
|
263
|
+
function CodeBlockLanguage({ className, children, ...props }: CodeBlockLanguageProps) {
|
|
264
|
+
const { language } = codeBlockVariants();
|
|
265
|
+
return (
|
|
266
|
+
<Text className={cn(language(), className)} {...props}>
|
|
267
|
+
{children}
|
|
268
|
+
</Text>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface CodeBlockActionsProps extends ViewProps {
|
|
273
|
+
className?: string;
|
|
274
|
+
children?: ReactNode;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function CodeBlockActions({ className, children, ...props }: CodeBlockActionsProps) {
|
|
278
|
+
const { actions } = codeBlockVariants();
|
|
279
|
+
return (
|
|
280
|
+
<View {...props} className={cn(actions(), className)}>
|
|
281
|
+
{children}
|
|
282
|
+
</View>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface CodeBlockCopyButtonProps {
|
|
287
|
+
className?: string;
|
|
288
|
+
/** How long the tick stays up before turning back into the copy glyph. */
|
|
289
|
+
timeout?: number;
|
|
290
|
+
onCopy?: () => void;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Copies the block's code.
|
|
295
|
+
*
|
|
296
|
+
* The tick is the whole feedback: there is no toast, because a snippet in a
|
|
297
|
+
* transcript is copied often and a notification for each one is noise.
|
|
298
|
+
*/
|
|
299
|
+
function CodeBlockCopyButton({ className, timeout = 2000, onCopy }: CodeBlockCopyButtonProps) {
|
|
300
|
+
const { code } = useCodeBlock('CodeBlock.CopyButton');
|
|
301
|
+
const { action } = codeBlockVariants();
|
|
302
|
+
const { copy, copied } = useCopyToClipboard({ timeout });
|
|
303
|
+
// Both glyphs are given a colour: the check's own fallback is white, which is
|
|
304
|
+
// invisible on this header in a light theme.
|
|
305
|
+
const rawTick = useCSSVariable('--color-success');
|
|
306
|
+
const rawIdle = useCSSVariable('--color-muted-foreground');
|
|
307
|
+
const tick = typeof rawTick === 'string' ? rawTick : undefined;
|
|
308
|
+
const idle = typeof rawIdle === 'string' ? rawIdle : undefined;
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<AnimatedPressable
|
|
312
|
+
accessibilityRole="button"
|
|
313
|
+
accessibilityLabel={copied ? 'Copied' : 'Copy code'}
|
|
314
|
+
accessibilityState={{ selected: copied }}
|
|
315
|
+
hitSlop={6}
|
|
316
|
+
onPress={() => {
|
|
317
|
+
void copy(code);
|
|
318
|
+
onCopy?.();
|
|
319
|
+
}}
|
|
320
|
+
className={cn(action(), className)}
|
|
321
|
+
>
|
|
322
|
+
{copied ? <CheckIcon size={14} color={tick} /> : <CopyIcon size={14} color={idle} />}
|
|
323
|
+
</AnimatedPressable>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
CodeBlockHeader.displayName = 'CodeBlock.Header';
|
|
328
|
+
CodeBlockFilename.displayName = 'CodeBlock.Filename';
|
|
329
|
+
CodeBlockLanguage.displayName = 'CodeBlock.Language';
|
|
330
|
+
CodeBlockActions.displayName = 'CodeBlock.Actions';
|
|
331
|
+
CodeBlockCopyButton.displayName = 'CodeBlock.CopyButton';
|
|
332
|
+
|
|
333
|
+
export { resolveLanguage, type CodeLanguage, type Token, type TokenKind } from './highlight';
|
|
334
|
+
|
|
335
|
+
export const CodeBlock = Object.assign(CodeBlockRoot, {
|
|
336
|
+
Header: CodeBlockHeader,
|
|
337
|
+
Filename: CodeBlockFilename,
|
|
338
|
+
Language: CodeBlockLanguage,
|
|
339
|
+
Actions: CodeBlockActions,
|
|
340
|
+
CopyButton: CodeBlockCopyButton,
|
|
341
|
+
});
|
|
@@ -106,12 +106,18 @@ export interface DatePickerProps<Mode extends DatePickerMode = 'single'> {
|
|
|
106
106
|
minDate?: Date;
|
|
107
107
|
/** Latest selectable day. */
|
|
108
108
|
maxDate?: Date;
|
|
109
|
+
/** Earliest month the `dropdown` caption offers. Defaults to `minDate`. */
|
|
110
|
+
startMonth?: Date;
|
|
111
|
+
/** Latest month the `dropdown` caption offers. Defaults to `maxDate`. */
|
|
112
|
+
endMonth?: Date;
|
|
109
113
|
/** Months side by side inside the panel. */
|
|
110
114
|
numberOfMonths?: number;
|
|
111
115
|
/** `dropdown` swaps the month caption for month and year pickers. */
|
|
112
116
|
captionLayout?: CalendarCaptionLayout;
|
|
113
117
|
/** `0` is Sunday. */
|
|
114
118
|
weekStartsOn?: number;
|
|
119
|
+
/** Let a tap on a neighbouring month's day select it. Off by default. */
|
|
120
|
+
selectOutsideDays?: boolean;
|
|
115
121
|
/** BCP 47 tag for the month and weekday names, and for the trigger's text. */
|
|
116
122
|
locale?: string;
|
|
117
123
|
/**
|
|
@@ -142,9 +148,12 @@ function DatePickerRoot<Mode extends DatePickerMode = 'single'>({
|
|
|
142
148
|
disabledDates,
|
|
143
149
|
minDate,
|
|
144
150
|
maxDate,
|
|
151
|
+
startMonth,
|
|
152
|
+
endMonth,
|
|
145
153
|
numberOfMonths = 1,
|
|
146
154
|
captionLayout = 'label',
|
|
147
155
|
weekStartsOn = 0,
|
|
156
|
+
selectOutsideDays = false,
|
|
148
157
|
locale,
|
|
149
158
|
calendar = 'gregory',
|
|
150
159
|
className,
|
|
@@ -211,17 +220,26 @@ function DatePickerRoot<Mode extends DatePickerMode = 'single'>({
|
|
|
211
220
|
would replace it. `self-center` for the same reason — the panel does
|
|
212
221
|
not have to know which presentation it ended up in. */}
|
|
213
222
|
<Popover.Content width="content-fit">
|
|
214
|
-
<View className="w-[
|
|
223
|
+
<View className="w-[308px] max-w-full self-center p-3">
|
|
224
|
+
{/*
|
|
225
|
+
Unbordered: the panel around it already draws one, and a card
|
|
226
|
+
inside a card is a seam. The calendar frames itself only when it is
|
|
227
|
+
standing on a page.
|
|
228
|
+
*/}
|
|
215
229
|
<Calendar
|
|
230
|
+
bordered={false}
|
|
216
231
|
mode={mode}
|
|
217
232
|
selected={value}
|
|
218
233
|
onSelect={handleSelect}
|
|
219
234
|
disabled={disabledDates}
|
|
220
235
|
minDate={minDate}
|
|
221
236
|
maxDate={maxDate}
|
|
237
|
+
startMonth={startMonth}
|
|
238
|
+
endMonth={endMonth}
|
|
222
239
|
numberOfMonths={numberOfMonths}
|
|
223
240
|
captionLayout={captionLayout}
|
|
224
241
|
weekStartsOn={weekStartsOn}
|
|
242
|
+
selectOutsideDays={selectOutsideDays}
|
|
225
243
|
locale={locale}
|
|
226
244
|
calendar={calendar}
|
|
227
245
|
/>
|