ink-cartridge 3.6.1
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/LICENSE +21 -0
- package/README.md +249 -0
- package/dist/binary-storage/BinaryStorage.d.ts +308 -0
- package/dist/binary-storage/BinaryStorage.js +548 -0
- package/dist/binary-storage/StreamingReader.d.ts +53 -0
- package/dist/binary-storage/StreamingReader.js +530 -0
- package/dist/binary-storage/index.d.ts +91 -0
- package/dist/binary-storage/index.js +91 -0
- package/dist/binary-storage/types.d.ts +400 -0
- package/dist/binary-storage/types.js +15 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +214 -0
- package/dist/cli/initTheme.d.ts +14 -0
- package/dist/cli/initTheme.js +124 -0
- package/dist/cli/makeLanguageType.d.ts +46 -0
- package/dist/cli/makeLanguageType.js +264 -0
- package/dist/cli/makeThemeType.d.ts +43 -0
- package/dist/cli/makeThemeType.js +222 -0
- package/dist/components/badge/Badge.d.ts +7 -0
- package/dist/components/badge/Badge.js +9 -0
- package/dist/components/dialog/ConfirmDialog.d.ts +23 -0
- package/dist/components/dialog/ConfirmDialog.js +57 -0
- package/dist/components/dialog/types.d.ts +20 -0
- package/dist/components/dialog/types.js +1 -0
- package/dist/components/divider/Divider.d.ts +8 -0
- package/dist/components/divider/Divider.js +10 -0
- package/dist/components/fold/Fold.d.ts +3 -0
- package/dist/components/fold/Fold.js +44 -0
- package/dist/components/fold/types.d.ts +28 -0
- package/dist/components/fold/types.js +1 -0
- package/dist/components/form/Field.d.ts +30 -0
- package/dist/components/form/Field.js +59 -0
- package/dist/components/form/Form.d.ts +32 -0
- package/dist/components/form/Form.js +163 -0
- package/dist/components/form/context.d.ts +14 -0
- package/dist/components/form/context.js +20 -0
- package/dist/components/form/types.d.ts +115 -0
- package/dist/components/form/types.js +1 -0
- package/dist/components/key-hint/KeyHint.d.ts +10 -0
- package/dist/components/key-hint/KeyHint.js +12 -0
- package/dist/components/multi-select/MultiSelectInput.d.ts +125 -0
- package/dist/components/multi-select/MultiSelectInput.js +287 -0
- package/dist/components/multi-select/types.d.ts +100 -0
- package/dist/components/multi-select/types.js +1 -0
- package/dist/components/number-input/NumberInput.d.ts +11 -0
- package/dist/components/number-input/NumberInput.js +44 -0
- package/dist/components/progress-bar/ProgressBar.d.ts +11 -0
- package/dist/components/progress-bar/ProgressBar.js +9 -0
- package/dist/components/search-input/SearchInput.d.ts +10 -0
- package/dist/components/search-input/SearchInput.js +15 -0
- package/dist/components/select/SelectInput.d.ts +100 -0
- package/dist/components/select/SelectInput.js +147 -0
- package/dist/components/select/types.d.ts +63 -0
- package/dist/components/select/types.js +1 -0
- package/dist/components/spinner/Spinner.d.ts +12 -0
- package/dist/components/spinner/Spinner.js +24 -0
- package/dist/components/tabs/Tabs.d.ts +3 -0
- package/dist/components/tabs/Tabs.js +57 -0
- package/dist/components/tabs/types.d.ts +32 -0
- package/dist/components/tabs/types.js +1 -0
- package/dist/components/text/TextInput.d.ts +39 -0
- package/dist/components/text/TextInput.js +233 -0
- package/dist/components/text/types.d.ts +62 -0
- package/dist/components/text/types.js +1 -0
- package/dist/dev/dev-screen.d.ts +36 -0
- package/dist/dev/dev-screen.js +142 -0
- package/dist/dev/entrance.d.ts +51 -0
- package/dist/dev/entrance.js +67 -0
- package/dist/dev/globalKey-display.d.ts +2 -0
- package/dist/dev/globalKey-display.js +8 -0
- package/dist/dev/types.d.ts +9 -0
- package/dist/dev/types.js +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +50 -0
- package/dist/keyboard/check-global-key.d.ts +18 -0
- package/dist/keyboard/check-global-key.js +52 -0
- package/dist/keyboard/context.d.ts +398 -0
- package/dist/keyboard/context.js +9 -0
- package/dist/keyboard/global-key-processor/index.d.ts +16 -0
- package/dist/keyboard/global-key-processor/index.js +75 -0
- package/dist/keyboard/global-sequence-processor/index.d.ts +14 -0
- package/dist/keyboard/global-sequence-processor/index.js +165 -0
- package/dist/keyboard/hook.d.ts +51 -0
- package/dist/keyboard/hook.js +105 -0
- package/dist/keyboard/index.d.ts +5 -0
- package/dist/keyboard/index.js +3 -0
- package/dist/keyboard/keyNormalizer.d.ts +37 -0
- package/dist/keyboard/keyNormalizer.js +119 -0
- package/dist/keyboard/layer-handler.d.ts +57 -0
- package/dist/keyboard/layer-handler.js +274 -0
- package/dist/keyboard/modal-processor/index.d.ts +16 -0
- package/dist/keyboard/modal-processor/index.js +97 -0
- package/dist/keyboard/overlay-processor/index.d.ts +16 -0
- package/dist/keyboard/overlay-processor/index.js +30 -0
- package/dist/keyboard/pipeline/chain.d.ts +10 -0
- package/dist/keyboard/pipeline/chain.js +45 -0
- package/dist/keyboard/pipeline/context.d.ts +34 -0
- package/dist/keyboard/pipeline/context.js +42 -0
- package/dist/keyboard/pipeline/index.d.ts +3 -0
- package/dist/keyboard/pipeline/index.js +2 -0
- package/dist/keyboard/provider.d.ts +32 -0
- package/dist/keyboard/provider.js +985 -0
- package/dist/keyboard/screen-stack-processor/index.d.ts +14 -0
- package/dist/keyboard/screen-stack-processor/index.js +32 -0
- package/dist/keyboard/types.d.ts +632 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/language/context.d.ts +7 -0
- package/dist/language/context.js +7 -0
- package/dist/language/hook.d.ts +19 -0
- package/dist/language/hook.js +26 -0
- package/dist/language/index.d.ts +3 -0
- package/dist/language/index.js +2 -0
- package/dist/language/provider.d.ts +30 -0
- package/dist/language/provider.js +155 -0
- package/dist/language/types.d.ts +124 -0
- package/dist/language/types.js +1 -0
- package/dist/screen/ModalContext.d.ts +11 -0
- package/dist/screen/ModalContext.js +12 -0
- package/dist/screen/OverlayContext.d.ts +9 -0
- package/dist/screen/OverlayContext.js +10 -0
- package/dist/screen/context.d.ts +61 -0
- package/dist/screen/context.js +8 -0
- package/dist/screen/current-screen.d.ts +16 -0
- package/dist/screen/current-screen.js +38 -0
- package/dist/screen/hook.d.ts +13 -0
- package/dist/screen/hook.js +20 -0
- package/dist/screen/index.d.ts +7 -0
- package/dist/screen/index.js +5 -0
- package/dist/screen/provider.d.ts +112 -0
- package/dist/screen/provider.js +577 -0
- package/dist/screen/registry.d.ts +27 -0
- package/dist/screen/registry.js +71 -0
- package/dist/screen/types.d.ts +227 -0
- package/dist/screen/types.js +1 -0
- package/dist/storage/index.d.ts +45 -0
- package/dist/storage/index.js +46 -0
- package/dist/storage/storage.d.ts +139 -0
- package/dist/storage/storage.js +236 -0
- package/dist/storage/types.d.ts +145 -0
- package/dist/storage/types.js +1 -0
- package/dist/theme/context.d.ts +7 -0
- package/dist/theme/context.js +7 -0
- package/dist/theme/hook.d.ts +11 -0
- package/dist/theme/hook.js +18 -0
- package/dist/theme/index.d.ts +4 -0
- package/dist/theme/index.js +2 -0
- package/dist/theme/provider.d.ts +18 -0
- package/dist/theme/provider.js +308 -0
- package/dist/theme/types.d.ts +53 -0
- package/dist/theme/types.js +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
interface ThemeFile {
|
|
2
|
+
/** Theme id from the file name (e.g. "dark"). */
|
|
3
|
+
id: string;
|
|
4
|
+
/** Flat key → value map (excluding 'id'). */
|
|
5
|
+
values: Record<string, string | boolean>;
|
|
6
|
+
}
|
|
7
|
+
export interface MakeThemeTypeOptions {
|
|
8
|
+
/** Directory containing {id}.json theme files. */
|
|
9
|
+
sourceDir: string;
|
|
10
|
+
/** Directory where the generated files will be written. */
|
|
11
|
+
outputDir: string;
|
|
12
|
+
/** Whether to re-generate on file changes. */
|
|
13
|
+
watch: boolean;
|
|
14
|
+
/** Debounce delay in milliseconds (default 500). */
|
|
15
|
+
debounceMs: number;
|
|
16
|
+
/** npm package name to import the original useTheme from. */
|
|
17
|
+
packageName: string;
|
|
18
|
+
}
|
|
19
|
+
/** Keys present in EVERY theme file. */
|
|
20
|
+
export declare function findCommonKeys(files: ThemeFile[]): string[];
|
|
21
|
+
/** Classify keys into color keys (string values) and style keys (boolean values). */
|
|
22
|
+
export declare function classifyKeys(files: ThemeFile[], commonKeys: string[]): {
|
|
23
|
+
colorKeys: string[];
|
|
24
|
+
styleKeys: string[];
|
|
25
|
+
};
|
|
26
|
+
/** @internal Exported for testing only. */
|
|
27
|
+
export declare function generateTypesContent(colorKeys: string[], styleKeys: string[]): string;
|
|
28
|
+
/** @internal Exported for testing only. */
|
|
29
|
+
export declare function generateRuntimeContent(packageName: string): string;
|
|
30
|
+
/** @internal Exported for testing only. */
|
|
31
|
+
export declare function escapeSingleQuote(s: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Generate type-safe theme bindings from a directory of {id}.json theme files.
|
|
34
|
+
*
|
|
35
|
+
* Produces two files in {@link MakeThemeTypeOptions.outputDir}:
|
|
36
|
+
* - `theme-types.d.ts` — type declarations (ThemeColorKey, ThemeStyleKey, ThemeKey)
|
|
37
|
+
* - `theme.ts` — typed `useTheme()` hook
|
|
38
|
+
*
|
|
39
|
+
* In watch mode it re-generates whenever a .json file in sourceDir changes,
|
|
40
|
+
* debounced to avoid rapid rebuilds.
|
|
41
|
+
*/
|
|
42
|
+
export declare function makeThemeType(options: MakeThemeTypeOptions): void;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
/* ------------------------------------------------------------------ */
|
|
4
|
+
/* File scanning */
|
|
5
|
+
/* ------------------------------------------------------------------ */
|
|
6
|
+
function scanThemeFiles(dirPath) {
|
|
7
|
+
const files = [];
|
|
8
|
+
let entries;
|
|
9
|
+
try {
|
|
10
|
+
entries = fs.readdirSync(dirPath);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return files;
|
|
14
|
+
}
|
|
15
|
+
for (const entry of entries.sort()) {
|
|
16
|
+
if (!entry.endsWith('.json'))
|
|
17
|
+
continue;
|
|
18
|
+
const id = entry.slice(0, -'.json'.length);
|
|
19
|
+
const fullPath = path.resolve(dirPath, entry);
|
|
20
|
+
try {
|
|
21
|
+
const raw = fs.readFileSync(fullPath, 'utf-8');
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
const values = {};
|
|
24
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
25
|
+
if (k === 'id')
|
|
26
|
+
continue;
|
|
27
|
+
if (typeof v === 'string' || typeof v === 'boolean') {
|
|
28
|
+
values[k] = v;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
files.push({ id, values });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
process.stderr.write(`[ink-cartridge] Warning: failed to parse "${entry}" — skipped\n`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return files;
|
|
38
|
+
}
|
|
39
|
+
/* ------------------------------------------------------------------ */
|
|
40
|
+
/* Key analysis */
|
|
41
|
+
/* ------------------------------------------------------------------ */
|
|
42
|
+
/** Keys present in EVERY theme file. */
|
|
43
|
+
export function findCommonKeys(files) {
|
|
44
|
+
if (files.length === 0)
|
|
45
|
+
return [];
|
|
46
|
+
const keySets = files.map((f) => new Set(Object.keys(f.values)));
|
|
47
|
+
const common = new Set(keySets[0]);
|
|
48
|
+
for (let i = 1; i < keySets.length; i++) {
|
|
49
|
+
for (const key of common) {
|
|
50
|
+
if (!keySets[i].has(key))
|
|
51
|
+
common.delete(key);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [...common].sort();
|
|
55
|
+
}
|
|
56
|
+
/** Classify keys into color keys (string values) and style keys (boolean values). */
|
|
57
|
+
export function classifyKeys(files, commonKeys) {
|
|
58
|
+
const colorKeys = [];
|
|
59
|
+
const styleKeys = [];
|
|
60
|
+
for (const key of commonKeys) {
|
|
61
|
+
// Peek at the first file's value type
|
|
62
|
+
const val = files[0]?.values[key];
|
|
63
|
+
if (typeof val === 'boolean') {
|
|
64
|
+
styleKeys.push(key);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
// string or anything else → color
|
|
68
|
+
colorKeys.push(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { colorKeys, styleKeys };
|
|
72
|
+
}
|
|
73
|
+
/* ------------------------------------------------------------------ */
|
|
74
|
+
/* Code generation */
|
|
75
|
+
/* ------------------------------------------------------------------ */
|
|
76
|
+
/** @internal Exported for testing only. */
|
|
77
|
+
export function generateTypesContent(colorKeys, styleKeys) {
|
|
78
|
+
const lines = [
|
|
79
|
+
'// Auto-generated by ink-cartridge makeThemeType.',
|
|
80
|
+
'// Do not edit manually.',
|
|
81
|
+
'',
|
|
82
|
+
];
|
|
83
|
+
// ThemeColorKey
|
|
84
|
+
if (colorKeys.length === 0) {
|
|
85
|
+
lines.push('export type ThemeColorKey = never;');
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
lines.push('export type ThemeColorKey =');
|
|
89
|
+
for (let i = 0; i < colorKeys.length; i++) {
|
|
90
|
+
const delim = i < colorKeys.length - 1 ? '' : ';';
|
|
91
|
+
lines.push(` | '${escapeSingleQuote(colorKeys[i])}'${delim}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
lines.push('');
|
|
95
|
+
// ThemeStyleKey
|
|
96
|
+
if (styleKeys.length === 0) {
|
|
97
|
+
lines.push('export type ThemeStyleKey = never;');
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
lines.push('export type ThemeStyleKey =');
|
|
101
|
+
for (let i = 0; i < styleKeys.length; i++) {
|
|
102
|
+
const delim = i < styleKeys.length - 1 ? '' : ';';
|
|
103
|
+
lines.push(` | '${escapeSingleQuote(styleKeys[i])}'${delim}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
lines.push('');
|
|
107
|
+
// Combined type
|
|
108
|
+
lines.push('/** Union of all theme keys (color + style). */');
|
|
109
|
+
lines.push('export type ThemeKey = ThemeColorKey | ThemeStyleKey;');
|
|
110
|
+
lines.push('');
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
|
113
|
+
/** @internal Exported for testing only. */
|
|
114
|
+
export function generateRuntimeContent(packageName) {
|
|
115
|
+
return (`// Auto-generated by ink-cartridge makeThemeType.\n` +
|
|
116
|
+
`// Do not edit manually.\n` +
|
|
117
|
+
`\n` +
|
|
118
|
+
`import { useTheme as rawUseTheme } from '${escapeSingleQuote(packageName)}';\n` +
|
|
119
|
+
`import type { ThemeColorKey, ThemeStyleKey } from './theme-types.js';\n` +
|
|
120
|
+
`\n` +
|
|
121
|
+
`/**\n` +
|
|
122
|
+
` * Typed theme hook. Drop-in replacement for the original \`useTheme\`.\n` +
|
|
123
|
+
` *\n` +
|
|
124
|
+
` * - \`color()\` only accepts keys classified as color values\n` +
|
|
125
|
+
` * - \`style()\` only accepts keys classified as style (boolean) values\n` +
|
|
126
|
+
` */\n` +
|
|
127
|
+
`export function useTheme(): Omit<ReturnType<typeof rawUseTheme>, 'color' | 'style'> & {\n` +
|
|
128
|
+
` color: (key: ThemeColorKey) => string | undefined;\n` +
|
|
129
|
+
` style: (key: ThemeStyleKey) => boolean | undefined;\n` +
|
|
130
|
+
`} {\n` +
|
|
131
|
+
` const ctx = rawUseTheme();\n` +
|
|
132
|
+
` return {\n` +
|
|
133
|
+
` ...ctx,\n` +
|
|
134
|
+
` color: (key: any) => ctx.color(key),\n` +
|
|
135
|
+
` style: (key: any) => ctx.style(key),\n` +
|
|
136
|
+
` };\n` +
|
|
137
|
+
`}\n`);
|
|
138
|
+
}
|
|
139
|
+
/* ------------------------------------------------------------------ */
|
|
140
|
+
/* Helpers */
|
|
141
|
+
/* ------------------------------------------------------------------ */
|
|
142
|
+
/** @internal Exported for testing only. */
|
|
143
|
+
export function escapeSingleQuote(s) {
|
|
144
|
+
return s.replace(/'/g, "\\'");
|
|
145
|
+
}
|
|
146
|
+
function writeOutput(outputDir, typesContent, runtimeContent) {
|
|
147
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
148
|
+
fs.writeFileSync(path.join(outputDir, 'theme-types.d.ts'), typesContent, 'utf-8');
|
|
149
|
+
fs.writeFileSync(path.join(outputDir, 'theme.ts'), runtimeContent, 'utf-8');
|
|
150
|
+
}
|
|
151
|
+
/* ------------------------------------------------------------------ */
|
|
152
|
+
/* Public API */
|
|
153
|
+
/* ------------------------------------------------------------------ */
|
|
154
|
+
/**
|
|
155
|
+
* Generate type-safe theme bindings from a directory of {id}.json theme files.
|
|
156
|
+
*
|
|
157
|
+
* Produces two files in {@link MakeThemeTypeOptions.outputDir}:
|
|
158
|
+
* - `theme-types.d.ts` — type declarations (ThemeColorKey, ThemeStyleKey, ThemeKey)
|
|
159
|
+
* - `theme.ts` — typed `useTheme()` hook
|
|
160
|
+
*
|
|
161
|
+
* In watch mode it re-generates whenever a .json file in sourceDir changes,
|
|
162
|
+
* debounced to avoid rapid rebuilds.
|
|
163
|
+
*/
|
|
164
|
+
export function makeThemeType(options) {
|
|
165
|
+
const { sourceDir, outputDir, debounceMs } = options;
|
|
166
|
+
if (!fs.existsSync(sourceDir)) {
|
|
167
|
+
process.stderr.write(`[ink-cartridge] Error: source directory not found — "${sourceDir}"\n`);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
generate(sourceDir, outputDir, options);
|
|
171
|
+
if (options.watch) {
|
|
172
|
+
let timer;
|
|
173
|
+
try {
|
|
174
|
+
fs.watch(sourceDir, (_eventType, filename) => {
|
|
175
|
+
if (!filename || !filename.endsWith('.json'))
|
|
176
|
+
return;
|
|
177
|
+
if (timer)
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
timer = setTimeout(() => {
|
|
180
|
+
process.stderr.write(`[ink-cartridge] Change detected in "${filename}", regenerating…\n`);
|
|
181
|
+
generate(sourceDir, outputDir, options);
|
|
182
|
+
}, debounceMs);
|
|
183
|
+
});
|
|
184
|
+
process.stderr.write(`[ink-cartridge] Watching "${sourceDir}" for changes (debounce: ${debounceMs}ms)…\n`);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
188
|
+
process.stderr.write(`[ink-cartridge] Error: watch mode failed — ${msg}\n`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/* ------------------------------------------------------------------ */
|
|
194
|
+
/* Core generate function */
|
|
195
|
+
/* ------------------------------------------------------------------ */
|
|
196
|
+
function generate(sourceDir, outputDir, options) {
|
|
197
|
+
const files = scanThemeFiles(sourceDir);
|
|
198
|
+
if (files.length === 0) {
|
|
199
|
+
process.stderr.write(`[ink-cartridge] Warning: no .json files found in "${sourceDir}"\n`);
|
|
200
|
+
const typesContent = generateTypesContent([], []);
|
|
201
|
+
const runtimeContent = generateRuntimeContent(options.packageName);
|
|
202
|
+
writeOutput(outputDir, typesContent, runtimeContent);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const commonKeys = findCommonKeys(files);
|
|
206
|
+
const { colorKeys, styleKeys } = classifyKeys(files, commonKeys);
|
|
207
|
+
// Warn about keys not in all files
|
|
208
|
+
const allKeys = new Set();
|
|
209
|
+
for (const f of files)
|
|
210
|
+
for (const k of Object.keys(f.values))
|
|
211
|
+
allKeys.add(k);
|
|
212
|
+
for (const key of allKeys) {
|
|
213
|
+
if (!commonKeys.includes(key)) {
|
|
214
|
+
const missingIn = files.filter((f) => !(key in f.values)).map((f) => f.id);
|
|
215
|
+
process.stderr.write(`[ink-cartridge] Warning: key "${key}" is missing in [${missingIn.join(', ')}] — excluded from type\n`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const typesContent = generateTypesContent(colorKeys, styleKeys);
|
|
219
|
+
const runtimeContent = generateRuntimeContent(options.packageName);
|
|
220
|
+
writeOutput(outputDir, typesContent, runtimeContent);
|
|
221
|
+
process.stderr.write(`[ink-cartridge] Generated ${colorKeys.length} color keys + ${styleKeys.length} style keys → ${path.resolve(outputDir)}\n`);
|
|
222
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
export function Badge({ children, color = 'cyan' }) {
|
|
4
|
+
return (React.createElement(Box, { marginRight: 1 },
|
|
5
|
+
React.createElement(Text, { backgroundColor: color, color: "black" },
|
|
6
|
+
' ',
|
|
7
|
+
children,
|
|
8
|
+
' ')));
|
|
9
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ConfirmDialogProps } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A modal confirmation dialog with two buttons.
|
|
5
|
+
*
|
|
6
|
+
* Designed to be displayed via {@link overlay}:
|
|
7
|
+
*
|
|
8
|
+
* ```tsx
|
|
9
|
+
* registerComponent(ConfirmDialog, {});
|
|
10
|
+
* overlay(ConfirmDialog, {
|
|
11
|
+
* title: 'delete',
|
|
12
|
+
* message: 'continue?',
|
|
13
|
+
* onConfirm: () => { deleteItem(); closeOverlay(); },
|
|
14
|
+
* onCancel: () => closeOverlay(),
|
|
15
|
+
* });
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Keyboard:
|
|
19
|
+
* - Tab / Shift+Tab — switch between buttons
|
|
20
|
+
* - Enter — trigger focused button
|
|
21
|
+
* - Esc — cancel
|
|
22
|
+
*/
|
|
23
|
+
export declare function ConfirmDialog({ title, message, confirmLabel, cancelLabel, onConfirm, onCancel, }: ConfirmDialogProps): React.JSX.Element;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useKeyboard, useFocusState } from '../../keyboard/hook.js';
|
|
4
|
+
/**
|
|
5
|
+
* A modal confirmation dialog with two buttons.
|
|
6
|
+
*
|
|
7
|
+
* Designed to be displayed via {@link overlay}:
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* registerComponent(ConfirmDialog, {});
|
|
11
|
+
* overlay(ConfirmDialog, {
|
|
12
|
+
* title: 'delete',
|
|
13
|
+
* message: 'continue?',
|
|
14
|
+
* onConfirm: () => { deleteItem(); closeOverlay(); },
|
|
15
|
+
* onCancel: () => closeOverlay(),
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Keyboard:
|
|
20
|
+
* - Tab / Shift+Tab — switch between buttons
|
|
21
|
+
* - Enter — trigger focused button
|
|
22
|
+
* - Esc — cancel
|
|
23
|
+
*/
|
|
24
|
+
export function ConfirmDialog({ title, message, confirmLabel = '确认', cancelLabel = '取消', onConfirm, onCancel, }) {
|
|
25
|
+
const { boundKeyboard, focusSet, focusUnregister } = useKeyboard();
|
|
26
|
+
const confirmFocused = useFocusState('dialog-confirm');
|
|
27
|
+
const cancelFocused = useFocusState('dialog-cancel');
|
|
28
|
+
// Keep stable refs so the keyboard effect doesn't capture stale callbacks
|
|
29
|
+
const onConfirmRef = useRef(onConfirm);
|
|
30
|
+
onConfirmRef.current = onConfirm;
|
|
31
|
+
const onCancelRef = useRef(onCancel);
|
|
32
|
+
onCancelRef.current = onCancel;
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
// Esc 在任何按钮上都是取消(屏幕级绑定,不受 focus 影响)
|
|
35
|
+
const unEsc = boundKeyboard(['escape'], () => onCancelRef.current());
|
|
36
|
+
const unConfirm = boundKeyboard(['return'], () => onConfirmRef.current(), { focusId: 'dialog-confirm' });
|
|
37
|
+
const unCancel = boundKeyboard(['return'], () => onCancelRef.current(), { focusId: 'dialog-cancel' });
|
|
38
|
+
focusSet('dialog-confirm');
|
|
39
|
+
return () => {
|
|
40
|
+
unEsc();
|
|
41
|
+
unConfirm();
|
|
42
|
+
unCancel();
|
|
43
|
+
focusUnregister('dialog-confirm');
|
|
44
|
+
focusUnregister('dialog-cancel');
|
|
45
|
+
};
|
|
46
|
+
}, []);
|
|
47
|
+
return (React.createElement(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 2, paddingY: 1 },
|
|
48
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
49
|
+
React.createElement(Text, { bold: true, color: "yellow" }, '⚠ ' + title)),
|
|
50
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
51
|
+
React.createElement(Text, null, message)),
|
|
52
|
+
React.createElement(Box, { justifyContent: "flex-end", gap: 2 },
|
|
53
|
+
React.createElement(Box, null,
|
|
54
|
+
React.createElement(Text, { color: cancelFocused ? 'cyan' : 'grey', bold: cancelFocused, underline: cancelFocused }, cancelLabel)),
|
|
55
|
+
React.createElement(Box, null,
|
|
56
|
+
React.createElement(Text, { color: confirmFocused ? 'green' : 'grey', bold: confirmFocused, underline: confirmFocused }, confirmLabel)))));
|
|
57
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Props for the ConfirmDialog component.
|
|
3
|
+
*
|
|
4
|
+
* A modal confirmation dialog with two buttons (confirm / cancel),
|
|
5
|
+
* designed to be shown via the overlay system.
|
|
6
|
+
*/
|
|
7
|
+
export interface ConfirmDialogProps {
|
|
8
|
+
/** Dialog title, displayed prominently at the top. */
|
|
9
|
+
readonly title: string;
|
|
10
|
+
/** Body message explaining what the user is confirming. */
|
|
11
|
+
readonly message: string;
|
|
12
|
+
/** Label for the confirm (rightmost) button. @default "确认" */
|
|
13
|
+
readonly confirmLabel?: string;
|
|
14
|
+
/** Label for the cancel (left) button. @default "取消" */
|
|
15
|
+
readonly cancelLabel?: string;
|
|
16
|
+
/** Called when the user confirms (Enter on confirm button). */
|
|
17
|
+
readonly onConfirm: () => void;
|
|
18
|
+
/** Called when the user cancels (Enter on cancel button or Esc). */
|
|
19
|
+
readonly onCancel: () => void;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
export function Divider({ label, char = '─', width = 50 }) {
|
|
4
|
+
if (label) {
|
|
5
|
+
const side = Math.max(0, Math.floor((width - label.length - 2) / 2));
|
|
6
|
+
const line = char.repeat(side) + ` ${label} ` + char.repeat(side);
|
|
7
|
+
return React.createElement(Text, { dimColor: true }, line);
|
|
8
|
+
}
|
|
9
|
+
return React.createElement(Text, { dimColor: true }, char.repeat(width));
|
|
10
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useKeyboard, useFocusState } from '../../keyboard/index.js';
|
|
4
|
+
export function Fold({ focusId, label, preview, children, expanded, onToggle, defaultExpanded, storage, storageKey, }) {
|
|
5
|
+
const isControlled = expanded !== undefined;
|
|
6
|
+
const [internalExpanded, setInternal] = useState(defaultExpanded ?? false);
|
|
7
|
+
const isExpanded = isControlled ? expanded : internalExpanded;
|
|
8
|
+
const isFocused = useFocusState(focusId);
|
|
9
|
+
const { boundKeyboard, focusUnregister } = useKeyboard();
|
|
10
|
+
const persistKey = storageKey ?? `fold:${focusId}`;
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!storage)
|
|
13
|
+
return;
|
|
14
|
+
let cancelled = false;
|
|
15
|
+
storage.read.b(persistKey, defaultExpanded ?? false).then((v) => {
|
|
16
|
+
if (!cancelled)
|
|
17
|
+
setInternal(v);
|
|
18
|
+
});
|
|
19
|
+
return () => { cancelled = true; };
|
|
20
|
+
}, [storage, persistKey, defaultExpanded]);
|
|
21
|
+
const toggle = () => {
|
|
22
|
+
if (isControlled) {
|
|
23
|
+
onToggle?.();
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
setInternal((prev) => {
|
|
27
|
+
const next = !prev;
|
|
28
|
+
storage?.write.b(persistKey, next);
|
|
29
|
+
return next;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const unSpace = boundKeyboard([' '], () => toggle(), { focusId });
|
|
35
|
+
return () => { unSpace(); focusUnregister(focusId); };
|
|
36
|
+
}, [focusId]);
|
|
37
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
38
|
+
React.createElement(Box, null,
|
|
39
|
+
React.createElement(Text, { color: isFocused ? 'cyan' : 'grey' }, isExpanded ? '▼ ' : '▶ '),
|
|
40
|
+
React.createElement(Text, null, label)),
|
|
41
|
+
isExpanded
|
|
42
|
+
? React.createElement(Box, null, children)
|
|
43
|
+
: (preview ? React.createElement(Box, null, preview) : null)));
|
|
44
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { StorageAPI } from "../../storage/index.js";
|
|
3
|
+
export interface FoldProps {
|
|
4
|
+
/** Focus target for the fold header bar. */
|
|
5
|
+
focusId: string;
|
|
6
|
+
/** Label shown on the header bar. */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Optional preview content displayed when folded. */
|
|
9
|
+
preview?: React.ReactNode;
|
|
10
|
+
/** Content rendered when the fold is expanded. */
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
/** Controlled: expanded state. */
|
|
13
|
+
expanded?: boolean;
|
|
14
|
+
/** Controlled: called when expand state is toggled. */
|
|
15
|
+
onToggle?: () => void;
|
|
16
|
+
/** Uncontrolled: initial expanded state. Defaults to false. */
|
|
17
|
+
defaultExpanded?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Optional persistence instance. When provided, the expanded state is
|
|
20
|
+
* automatically saved and restored across sessions.
|
|
21
|
+
*/
|
|
22
|
+
storage?: StorageAPI;
|
|
23
|
+
/**
|
|
24
|
+
* Storage key used for persistence. Defaults to `"fold:<focusId>"` when
|
|
25
|
+
* not provided.
|
|
26
|
+
*/
|
|
27
|
+
storageKey?: string;
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { FieldProps } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A single field in a {@link Form}.
|
|
5
|
+
*
|
|
6
|
+
* Uses the **render prop pattern** — children receives
|
|
7
|
+
* `{ value, error, onChange, focusId }` and returns the input component.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities:
|
|
10
|
+
* - Registers itself (name + rules + defaultValue + focusId) with Form on mount
|
|
11
|
+
* - Reads current value and error from Form context
|
|
12
|
+
* - Wraps `onChange` to call Form's `setFieldValue` (updates value + clears error)
|
|
13
|
+
* - Auto-generates `focusId` from `name` if not explicitly provided
|
|
14
|
+
* - Unregisters on unmount
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* <Field name="email" rules={[required]} defaultValue="">
|
|
19
|
+
* {({ value, error, onChange, focusId }) => (
|
|
20
|
+
* <TextInput
|
|
21
|
+
* focusId={focusId}
|
|
22
|
+
* value={value}
|
|
23
|
+
* onChange={onChange}
|
|
24
|
+
* placeholder="Email"
|
|
25
|
+
* />
|
|
26
|
+
* )}
|
|
27
|
+
* </Field>
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function Field({ name, children, rules, defaultValue, focusId }: FieldProps): React.JSX.Element;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React, { useEffect, useCallback, useMemo } from 'react';
|
|
2
|
+
import { useFormContext } from './context.js';
|
|
3
|
+
/**
|
|
4
|
+
* A single field in a {@link Form}.
|
|
5
|
+
*
|
|
6
|
+
* Uses the **render prop pattern** — children receives
|
|
7
|
+
* `{ value, error, onChange, focusId }` and returns the input component.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities:
|
|
10
|
+
* - Registers itself (name + rules + defaultValue + focusId) with Form on mount
|
|
11
|
+
* - Reads current value and error from Form context
|
|
12
|
+
* - Wraps `onChange` to call Form's `setFieldValue` (updates value + clears error)
|
|
13
|
+
* - Auto-generates `focusId` from `name` if not explicitly provided
|
|
14
|
+
* - Unregisters on unmount
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* <Field name="email" rules={[required]} defaultValue="">
|
|
19
|
+
* {({ value, error, onChange, focusId }) => (
|
|
20
|
+
* <TextInput
|
|
21
|
+
* focusId={focusId}
|
|
22
|
+
* value={value}
|
|
23
|
+
* onChange={onChange}
|
|
24
|
+
* placeholder="Email"
|
|
25
|
+
* />
|
|
26
|
+
* )}
|
|
27
|
+
* </Field>
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function Field({ name, children, rules, defaultValue, focusId }) {
|
|
31
|
+
const { values, errors, setFieldValue, registerField, unregisterField } = useFormContext();
|
|
32
|
+
/** Auto-generate focusId from field name if not explicitly provided. */
|
|
33
|
+
const effectiveFocusId = focusId ?? `${name}-field`;
|
|
34
|
+
/** Register field with Form on mount, unregister on unmount. */
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
registerField(name, defaultValue, rules, effectiveFocusId);
|
|
37
|
+
return () => unregisterField(name);
|
|
38
|
+
}, [name, registerField, unregisterField]);
|
|
39
|
+
/**
|
|
40
|
+
* Stable onChange handler — calls Form's setFieldValue,
|
|
41
|
+
* which updates the value and clears any validation error.
|
|
42
|
+
*/
|
|
43
|
+
const onChange = useCallback((value) => setFieldValue(name, value), [name, setFieldValue]);
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the field value. If the Form has no value for this name yet
|
|
46
|
+
* (e.g. before the first render's registerField effect), fall back to
|
|
47
|
+
* the Field's own defaultValue. This avoids passing undefined to input
|
|
48
|
+
* components on the initial render.
|
|
49
|
+
*/
|
|
50
|
+
const resolvedValue = values[name] !== undefined ? values[name] : (defaultValue ?? '');
|
|
51
|
+
/** Memoize the render props to avoid unnecessary re-renders. */
|
|
52
|
+
const fieldProps = useMemo(() => ({
|
|
53
|
+
value: resolvedValue,
|
|
54
|
+
error: errors[name],
|
|
55
|
+
onChange,
|
|
56
|
+
focusId: effectiveFocusId,
|
|
57
|
+
}), [resolvedValue, errors[name], onChange, effectiveFocusId]);
|
|
58
|
+
return React.createElement(React.Fragment, null, children(fieldProps));
|
|
59
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { FormProps } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Form container — manages field values, validation, and submission.
|
|
5
|
+
*
|
|
6
|
+
* Integrates with ink-cartridge's keyboard and focus system:
|
|
7
|
+
* - **Ctrl+Enter** triggers `submitForm()` (avoids conflicting with TextInput's Enter)
|
|
8
|
+
* - On validation failure, calls `focusSet()` on the first error field
|
|
9
|
+
*
|
|
10
|
+
* Must be used inside a `<KeyboardProvider>` for keyboard bindings to work.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* <Form
|
|
15
|
+
* initialValues={{ email: '', password: '' }}
|
|
16
|
+
* onSubmit={(values) => handleLogin(values)}
|
|
17
|
+
* onError={(errors) => console.log('Validation failed', errors)}
|
|
18
|
+
* >
|
|
19
|
+
* <Field name="email" rules={[required, isEmail]}>
|
|
20
|
+
* {({ value, onChange, error, focusId }) => (
|
|
21
|
+
* <TextInput focusId={focusId} value={value} onChange={onChange} />
|
|
22
|
+
* )}
|
|
23
|
+
* </Field>
|
|
24
|
+
* <Field name="password" rules={[required]}>
|
|
25
|
+
* {({ value, onChange, error, focusId }) => (
|
|
26
|
+
* <TextInput focusId={focusId} value={value} onChange={onChange} mask="*" />
|
|
27
|
+
* )}
|
|
28
|
+
* </Field>
|
|
29
|
+
* </Form>
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare function Form({ children, onSubmit, onError, initialValues, submitRef }: FormProps): React.JSX.Element;
|