wp-typia 0.16.0 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,460 @@
1
+ import {
2
+ createElement,
3
+ type ReactNode,
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useMemo,
8
+ useRef,
9
+ } from "react";
10
+
11
+ import { useScopedKeyboard } from "@bunli/runtime/app";
12
+ import {
13
+ type SelectOption,
14
+ createKeyMatcher,
15
+ useFormContext,
16
+ useFormField,
17
+ useTuiTheme,
18
+ } from "@bunli/tui";
19
+
20
+ import {
21
+ FIRST_PARTY_CHECKBOX_FIELD_BODY_HEIGHT,
22
+ FIRST_PARTY_FIELD_GAP,
23
+ FIRST_PARTY_SELECT_FIELD_CONTROL_HEIGHT,
24
+ FIRST_PARTY_SELECT_FIELD_LABEL_GAP,
25
+ } from "./first-party-form-model";
26
+
27
+ const checkboxKeymap = createKeyMatcher({
28
+ toggle: ["space", "enter"],
29
+ });
30
+
31
+ const navigationKeymap = createKeyMatcher({
32
+ nextField: ["tab"],
33
+ previousField: ["shift+tab"],
34
+ });
35
+
36
+ const submitShortcutKeymap = createKeyMatcher({
37
+ submitShortcut: ["ctrl+s"],
38
+ });
39
+
40
+ const selectKeymap = createKeyMatcher({
41
+ next: ["down", "enter", "right", "space"],
42
+ previous: ["left", "up"],
43
+ });
44
+
45
+ function isNextFieldKey(key: Parameters<typeof navigationKeymap.match>[1]) {
46
+ return (
47
+ navigationKeymap.match("nextField", key) ||
48
+ key.sequence === "\t" ||
49
+ (key.ctrl === true && key.name === "i" && key.shift !== true)
50
+ );
51
+ }
52
+
53
+ function isPreviousFieldKey(key: Parameters<typeof navigationKeymap.match>[1]) {
54
+ return (
55
+ navigationKeymap.match("previousField", key) ||
56
+ key.sequence === "\u001b[Z" ||
57
+ (key.name === "tab" && key.shift === true)
58
+ );
59
+ }
60
+
61
+ function isSubmitShortcutKey(key: Parameters<typeof submitShortcutKeymap.match>[1]) {
62
+ return (
63
+ submitShortcutKeymap.match("submitShortcut", key) ||
64
+ key.sequence === "\x13" ||
65
+ (key.ctrl === true && key.name === "s")
66
+ );
67
+ }
68
+
69
+ function isSelectNextKey(key: Parameters<typeof selectKeymap.match>[1]) {
70
+ return (
71
+ selectKeymap.match("next", key) ||
72
+ key.sequence === "\x1b[B" ||
73
+ key.sequence === "\x1b[C" ||
74
+ key.name === "down" ||
75
+ key.name === "right" ||
76
+ key.sequence === " "
77
+ );
78
+ }
79
+
80
+ function isSelectPreviousKey(key: Parameters<typeof selectKeymap.match>[1]) {
81
+ return (
82
+ selectKeymap.match("previous", key) ||
83
+ key.sequence === "\x1b[A" ||
84
+ key.sequence === "\x1b[D" ||
85
+ key.name === "up" ||
86
+ key.name === "left"
87
+ );
88
+ }
89
+
90
+ function isCheckboxToggleKey(key: Parameters<typeof checkboxKeymap.match>[1]) {
91
+ return (
92
+ checkboxKeymap.match("toggle", key) ||
93
+ key.sequence === " " ||
94
+ key.sequence === "\r" ||
95
+ key.sequence === "\n"
96
+ );
97
+ }
98
+
99
+ function useFirstPartyFieldNavigation(options: {
100
+ focused: boolean;
101
+ keyboardScopeId: string;
102
+ nextFieldName?: string;
103
+ previousFieldName?: string;
104
+ }) {
105
+ const { focusField, submit } = useFormContext();
106
+ const { focused, keyboardScopeId, nextFieldName, previousFieldName } = options;
107
+
108
+ useScopedKeyboard(
109
+ keyboardScopeId,
110
+ (key) => {
111
+ if (!focused) {
112
+ return false;
113
+ }
114
+
115
+ if (isSubmitShortcutKey(key)) {
116
+ submit();
117
+ return true;
118
+ }
119
+
120
+ if (isNextFieldKey(key) && nextFieldName) {
121
+ focusField(nextFieldName);
122
+ return true;
123
+ }
124
+
125
+ if (isPreviousFieldKey(key) && previousFieldName) {
126
+ focusField(previousFieldName);
127
+ return true;
128
+ }
129
+
130
+ return false;
131
+ },
132
+ { active: focused },
133
+ );
134
+ }
135
+
136
+ export function FirstPartyTextField({
137
+ defaultValue = "",
138
+ description,
139
+ label,
140
+ name,
141
+ nextFieldName,
142
+ placeholder,
143
+ previousFieldName,
144
+ required,
145
+ }: {
146
+ defaultValue?: string;
147
+ description?: string;
148
+ label: string;
149
+ name: string;
150
+ nextFieldName?: string;
151
+ placeholder?: string;
152
+ previousFieldName?: string;
153
+ required?: boolean;
154
+ }) {
155
+ const { tokens } = useTuiTheme();
156
+ const reactScopeId = useId();
157
+ const field = useFormField<string>(name, {
158
+ defaultValue,
159
+ submitOnEnter: true,
160
+ });
161
+ const keyboardScopeId = `first-party-text:${name}:${reactScopeId}`;
162
+
163
+ useFirstPartyFieldNavigation({
164
+ focused: field.focused,
165
+ keyboardScopeId,
166
+ nextFieldName,
167
+ previousFieldName,
168
+ });
169
+
170
+ return createElement(
171
+ "box",
172
+ { style: { flexDirection: "column", marginBottom: FIRST_PARTY_FIELD_GAP, gap: 1 } },
173
+ createElement("text", {
174
+ content: `${field.focused ? ">" : " "} ${label}${required ? " *" : ""}`,
175
+ fg: field.focused ? tokens.accent : tokens.textPrimary,
176
+ }),
177
+ description
178
+ ? createElement("text", {
179
+ content: description,
180
+ fg: tokens.textMuted,
181
+ })
182
+ : null,
183
+ createElement(
184
+ "box",
185
+ {
186
+ border: true,
187
+ height: 3,
188
+ style: {
189
+ borderColor: field.error
190
+ ? tokens.textDanger
191
+ : field.focused
192
+ ? tokens.accent
193
+ : tokens.borderMuted,
194
+ },
195
+ },
196
+ createElement("input", {
197
+ focused: field.focused,
198
+ onInput: (nextValue: string) => {
199
+ field.setValue(nextValue);
200
+ },
201
+ onSubmit: (submittedValue: string) => {
202
+ field.setValue(submittedValue);
203
+ field.blur();
204
+ },
205
+ placeholder,
206
+ style: {
207
+ focusedBackgroundColor: tokens.backgroundMuted,
208
+ },
209
+ value: field.value ?? "",
210
+ }),
211
+ ),
212
+ field.error
213
+ ? createElement("text", { content: field.error, fg: tokens.textDanger })
214
+ : null,
215
+ );
216
+ }
217
+
218
+ export function FirstPartySelectField({
219
+ defaultValue,
220
+ label,
221
+ name,
222
+ nextFieldName,
223
+ options,
224
+ previousFieldName,
225
+ }: {
226
+ defaultValue?: string;
227
+ label: string;
228
+ name: string;
229
+ nextFieldName?: string;
230
+ options: SelectOption[];
231
+ previousFieldName?: string;
232
+ }) {
233
+ const { tokens } = useTuiTheme();
234
+ const reactScopeId = useId();
235
+ const field = useFormField<string>(name, {
236
+ defaultValue: defaultValue ?? String(options[0]?.value ?? ""),
237
+ submitOnEnter: false,
238
+ });
239
+
240
+ const matchingIndex = useMemo(
241
+ () => options.findIndex((option) => option.value === field.value),
242
+ [field.value, options],
243
+ );
244
+ const selectedIndex = matchingIndex >= 0 ? matchingIndex : 0;
245
+
246
+ const selectedOption = options[selectedIndex] ?? options[0];
247
+ const keyboardScopeId = `first-party-select:${name}:${reactScopeId}`;
248
+
249
+ useEffect(() => {
250
+ const fallbackOption = options[0];
251
+ if (!fallbackOption || matchingIndex >= 0) {
252
+ return;
253
+ }
254
+
255
+ const fallbackValue = String(fallbackOption.value);
256
+ if (field.value === fallbackValue) {
257
+ return;
258
+ }
259
+
260
+ field.setValue(fallbackValue);
261
+ }, [field, matchingIndex, options]);
262
+
263
+ useFirstPartyFieldNavigation({
264
+ focused: field.focused,
265
+ keyboardScopeId,
266
+ nextFieldName,
267
+ previousFieldName,
268
+ });
269
+
270
+ const moveSelection = useCallback(
271
+ (delta: number) => {
272
+ if (!selectedOption || options.length === 0) {
273
+ return;
274
+ }
275
+
276
+ const nextIndex = (selectedIndex + delta + options.length) % options.length;
277
+ const nextOption = options[nextIndex];
278
+ if (!nextOption) {
279
+ return;
280
+ }
281
+
282
+ field.setValue(String(nextOption.value));
283
+ },
284
+ [field, options, selectedIndex, selectedOption],
285
+ );
286
+
287
+ useScopedKeyboard(
288
+ keyboardScopeId,
289
+ (key) => {
290
+ if (!field.focused) {
291
+ return false;
292
+ }
293
+
294
+ if (isSelectNextKey(key)) {
295
+ moveSelection(1);
296
+ return true;
297
+ }
298
+
299
+ if (isSelectPreviousKey(key)) {
300
+ moveSelection(-1);
301
+ return true;
302
+ }
303
+
304
+ return false;
305
+ },
306
+ { active: field.focused },
307
+ );
308
+
309
+ return createElement(
310
+ "box",
311
+ {
312
+ style: {
313
+ flexDirection: "column",
314
+ gap: FIRST_PARTY_SELECT_FIELD_LABEL_GAP,
315
+ marginBottom: FIRST_PARTY_FIELD_GAP,
316
+ },
317
+ },
318
+ createElement("text", {
319
+ content: `${field.focused ? ">" : " "} ${label}`,
320
+ fg: field.focused ? tokens.accent : tokens.textPrimary,
321
+ }),
322
+ createElement(
323
+ "box",
324
+ {
325
+ border: true,
326
+ height: FIRST_PARTY_SELECT_FIELD_CONTROL_HEIGHT,
327
+ style: {
328
+ borderColor: field.error
329
+ ? tokens.textDanger
330
+ : field.focused
331
+ ? tokens.accent
332
+ : tokens.borderMuted,
333
+ },
334
+ },
335
+ createElement("text", {
336
+ content: `${field.focused ? "▶" : " "} ${selectedOption?.name ?? ""}`,
337
+ fg: field.focused ? tokens.accent : tokens.textPrimary,
338
+ }),
339
+ ),
340
+ selectedOption?.description
341
+ ? createElement("text", {
342
+ content: ` ${selectedOption.description}`,
343
+ fg: tokens.textMuted,
344
+ })
345
+ : null,
346
+ field.error
347
+ ? createElement("text", { content: field.error, fg: tokens.textDanger })
348
+ : null,
349
+ );
350
+ }
351
+
352
+ export function FirstPartyCheckboxField({
353
+ label,
354
+ name,
355
+ nextFieldName,
356
+ previousFieldName,
357
+ }: {
358
+ label: string;
359
+ name: string;
360
+ nextFieldName?: string;
361
+ previousFieldName?: string;
362
+ }) {
363
+ const { tokens } = useTuiTheme();
364
+ const reactScopeId = useId();
365
+ const field = useFormField<boolean>(name, {
366
+ defaultValue: false,
367
+ submitOnEnter: false,
368
+ });
369
+ const keyboardScopeId = `first-party-checkbox:${name}:${reactScopeId}`;
370
+
371
+ useFirstPartyFieldNavigation({
372
+ focused: field.focused,
373
+ keyboardScopeId,
374
+ nextFieldName,
375
+ previousFieldName,
376
+ });
377
+
378
+ const toggle = useCallback(() => {
379
+ field.setValue(!field.value);
380
+ }, [field]);
381
+
382
+ useScopedKeyboard(
383
+ keyboardScopeId,
384
+ (key) => {
385
+ if (!field.focused) {
386
+ return false;
387
+ }
388
+
389
+ if (isCheckboxToggleKey(key)) {
390
+ toggle();
391
+ return true;
392
+ }
393
+
394
+ return false;
395
+ },
396
+ { active: field.focused },
397
+ );
398
+
399
+ return createElement(
400
+ "box",
401
+ {
402
+ style: {
403
+ flexDirection: "column",
404
+ height: FIRST_PARTY_CHECKBOX_FIELD_BODY_HEIGHT,
405
+ marginBottom: FIRST_PARTY_FIELD_GAP,
406
+ },
407
+ },
408
+ createElement("text", {
409
+ content: `${field.focused ? ">" : " "} ${field.value ? "[x]" : "[ ]"} ${label}`,
410
+ fg: field.focused ? tokens.accent : tokens.textPrimary,
411
+ }),
412
+ field.error
413
+ ? createElement("text", { content: field.error, fg: tokens.textDanger })
414
+ : null,
415
+ );
416
+ }
417
+
418
+ export function FirstPartyScrollBox({
419
+ children,
420
+ scrollTop,
421
+ viewportHeight,
422
+ }: {
423
+ children?: ReactNode;
424
+ scrollTop: number;
425
+ viewportHeight: number;
426
+ }) {
427
+ const { tokens } = useTuiTheme();
428
+ const bodyRef = useRef<{ scrollTop: number } | null>(null);
429
+
430
+ useEffect(() => {
431
+ if (!bodyRef.current) {
432
+ return;
433
+ }
434
+
435
+ bodyRef.current.scrollTop = scrollTop;
436
+ }, [scrollTop]);
437
+
438
+ return createElement(
439
+ "scrollbox",
440
+ {
441
+ ref: bodyRef,
442
+ height: viewportHeight,
443
+ scrollY: true,
444
+ scrollbarOptions: {
445
+ visible: true,
446
+ trackOptions: {
447
+ backgroundColor: tokens.backgroundMuted,
448
+ foregroundColor: tokens.borderMuted,
449
+ },
450
+ },
451
+ viewportOptions: { width: "100%" },
452
+ contentOptions: { width: "100%" },
453
+ },
454
+ createElement(
455
+ "box",
456
+ { width: "100%", style: { flexDirection: "column" } },
457
+ children,
458
+ ),
459
+ );
460
+ }
@@ -1,5 +1,11 @@
1
1
  import { createElement, useEffect, useState, type ComponentType } from "react";
2
2
 
3
+ import {
4
+ resolveLazyFlowComponent,
5
+ useAlternateBufferExitKeys,
6
+ useAlternateBufferLifecycle,
7
+ } from "./alternate-buffer-lifecycle";
8
+
3
9
  type LazyFlowProps<TProps> = {
4
10
  loader: () => Promise<{ default: ComponentType<TProps> }>;
5
11
  props: TProps;
@@ -7,31 +13,30 @@ type LazyFlowProps<TProps> = {
7
13
 
8
14
  export function LazyFlow<TProps>({ loader, props }: LazyFlowProps<TProps>) {
9
15
  const [Component, setComponent] = useState<ComponentType<TProps> | null>(null);
10
- const [errorMessage, setErrorMessage] = useState<string | null>(null);
16
+ const { handleFailure } = useAlternateBufferLifecycle("wp-typia TUI flow failed", {
17
+ enableExitKeys: false,
18
+ });
19
+
20
+ useAlternateBufferExitKeys({
21
+ enabled: Component === null,
22
+ });
11
23
 
12
24
  useEffect(() => {
13
25
  let disposed = false;
14
26
 
15
- void loader()
16
- .then((module) => {
17
- if (!disposed) {
18
- setComponent(() => module.default);
19
- }
20
- })
21
- .catch((error) => {
22
- if (!disposed) {
23
- setErrorMessage(error instanceof Error ? error.message : String(error));
24
- }
25
- });
27
+ void resolveLazyFlowComponent({
28
+ isDisposed: () => disposed,
29
+ loader,
30
+ onFailure: handleFailure,
31
+ onLoaded: (component) => {
32
+ setComponent(() => component);
33
+ },
34
+ });
26
35
 
27
36
  return () => {
28
37
  disposed = true;
29
38
  };
30
- }, [loader]);
31
-
32
- if (errorMessage) {
33
- return errorMessage;
34
- }
39
+ }, [handleFailure, loader]);
35
40
 
36
41
  if (!Component) {
37
42
  return null;
@@ -0,0 +1,126 @@
1
+ import { z } from "zod";
2
+
3
+ import {
4
+ FIRST_PARTY_CHECKBOX_FIELD_BODY_HEIGHT,
5
+ FIRST_PARTY_SELECT_FIELD_BODY_HEIGHT,
6
+ FIRST_PARTY_TEXT_FIELD_BODY_HEIGHT,
7
+ getFirstPartyScrollTop,
8
+ getFirstPartyViewportHeight,
9
+ } from "./first-party-form-model";
10
+
11
+ export const migrateFlowSchema = z.object({
12
+ all: z.boolean().default(false),
13
+ command: z.enum([
14
+ "init",
15
+ "snapshot",
16
+ "plan",
17
+ "wizard",
18
+ "diff",
19
+ "scaffold",
20
+ "verify",
21
+ "doctor",
22
+ "fixtures",
23
+ "fuzz",
24
+ ]),
25
+ "current-migration-version": z.string().optional(),
26
+ force: z.boolean().default(false),
27
+ "from-migration-version": z.string().optional(),
28
+ iterations: z.string().optional(),
29
+ "migration-version": z.string().optional(),
30
+ seed: z.string().optional(),
31
+ "to-migration-version": z.string().optional(),
32
+ });
33
+
34
+ export type MigrateFlowValues = z.infer<typeof migrateFlowSchema>;
35
+
36
+ export type MigrateFieldName =
37
+ | "command"
38
+ | "current-migration-version"
39
+ | "migration-version"
40
+ | "from-migration-version"
41
+ | "to-migration-version"
42
+ | "all"
43
+ | "force"
44
+ | "iterations"
45
+ | "seed";
46
+
47
+ const MIGRATE_FIELD_HEIGHTS: Record<MigrateFieldName, number> = {
48
+ all: FIRST_PARTY_CHECKBOX_FIELD_BODY_HEIGHT,
49
+ command: FIRST_PARTY_SELECT_FIELD_BODY_HEIGHT,
50
+ "current-migration-version": FIRST_PARTY_TEXT_FIELD_BODY_HEIGHT,
51
+ force: FIRST_PARTY_CHECKBOX_FIELD_BODY_HEIGHT,
52
+ "from-migration-version": FIRST_PARTY_TEXT_FIELD_BODY_HEIGHT,
53
+ iterations: FIRST_PARTY_TEXT_FIELD_BODY_HEIGHT,
54
+ "migration-version": FIRST_PARTY_TEXT_FIELD_BODY_HEIGHT,
55
+ seed: FIRST_PARTY_TEXT_FIELD_BODY_HEIGHT,
56
+ "to-migration-version": FIRST_PARTY_TEXT_FIELD_BODY_HEIGHT,
57
+ };
58
+
59
+ export function getVisibleMigrateFieldNames(
60
+ values: Partial<MigrateFlowValues>,
61
+ ): Array<MigrateFieldName> {
62
+ switch (values.command ?? "plan") {
63
+ case "init":
64
+ return ["command", "current-migration-version"];
65
+ case "snapshot":
66
+ return ["command", "migration-version"];
67
+ case "plan":
68
+ return ["command", "from-migration-version", "to-migration-version"];
69
+ case "wizard":
70
+ return ["command"];
71
+ case "diff":
72
+ return ["command", "from-migration-version", "to-migration-version"];
73
+ case "scaffold":
74
+ return ["command", "from-migration-version", "to-migration-version"];
75
+ case "verify":
76
+ return ["command", "from-migration-version", "all"];
77
+ case "doctor":
78
+ return ["command", "from-migration-version", "all"];
79
+ case "fixtures":
80
+ return ["command", "from-migration-version", "to-migration-version", "all", "force"];
81
+ case "fuzz":
82
+ return ["command", "from-migration-version", "all", "iterations", "seed"];
83
+ default:
84
+ return ["command"];
85
+ }
86
+ }
87
+
88
+ export function getMigrateViewportHeight(terminalHeight = 24): number {
89
+ return getFirstPartyViewportHeight(terminalHeight);
90
+ }
91
+
92
+ export function getMigrateScrollTop(options: {
93
+ activeFieldName: string | null;
94
+ values: Partial<MigrateFlowValues>;
95
+ viewportHeight: number;
96
+ }): number {
97
+ const { activeFieldName, values, viewportHeight } = options;
98
+ return getFirstPartyScrollTop({
99
+ activeFieldName,
100
+ fieldHeights: MIGRATE_FIELD_HEIGHTS,
101
+ visibleFieldNames: getVisibleMigrateFieldNames(values),
102
+ viewportHeight,
103
+ });
104
+ }
105
+
106
+ export function sanitizeMigrateSubmitValues(values: MigrateFlowValues): Record<string, unknown> {
107
+ const visibleFields = new Set(getVisibleMigrateFieldNames(values));
108
+ const sanitized: Record<string, unknown> = {};
109
+
110
+ for (const fieldName of visibleFields) {
111
+ const value = values[fieldName];
112
+ if (typeof value === "string") {
113
+ const trimmed = value.trim();
114
+ if (trimmed.length > 0) {
115
+ sanitized[fieldName] = trimmed;
116
+ }
117
+ continue;
118
+ }
119
+
120
+ if (value !== undefined && value !== null) {
121
+ sanitized[fieldName] = value;
122
+ }
123
+ }
124
+
125
+ return sanitized;
126
+ }