@seed-design/lynx-react 0.3.1 → 0.4.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 (78) hide show
  1. package/lib/components/Field/Field.d.ts +47 -0
  2. package/lib/components/Field/Field.d.ts.map +1 -0
  3. package/lib/components/Field/Field.jsx +121 -0
  4. package/lib/components/Field/Field.namespace.d.ts +2 -0
  5. package/lib/components/Field/Field.namespace.d.ts.map +1 -0
  6. package/lib/components/Field/Field.namespace.js +16 -0
  7. package/lib/components/Field/context.d.ts +19 -0
  8. package/lib/components/Field/context.d.ts.map +1 -0
  9. package/lib/components/Field/context.js +10 -0
  10. package/lib/components/Field/index.d.ts +3 -0
  11. package/lib/components/Field/index.d.ts.map +1 -0
  12. package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.d.ts +42 -0
  13. package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.d.ts.map +1 -0
  14. package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.jsx +140 -0
  15. package/lib/components/KeyboardAvoidingScrollView/context.d.ts +17 -0
  16. package/lib/components/KeyboardAvoidingScrollView/context.d.ts.map +1 -0
  17. package/lib/components/KeyboardAvoidingScrollView/context.js +12 -0
  18. package/lib/components/KeyboardAvoidingScrollView/engine.d.ts +50 -0
  19. package/lib/components/KeyboardAvoidingScrollView/engine.d.ts.map +1 -0
  20. package/lib/components/KeyboardAvoidingScrollView/engine.js +259 -0
  21. package/lib/components/KeyboardAvoidingScrollView/geometry.d.ts +50 -0
  22. package/lib/components/KeyboardAvoidingScrollView/geometry.d.ts.map +1 -0
  23. package/lib/components/KeyboardAvoidingScrollView/geometry.js +90 -0
  24. package/lib/components/KeyboardAvoidingScrollView/index.d.ts +2 -0
  25. package/lib/components/KeyboardAvoidingScrollView/index.d.ts.map +1 -0
  26. package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.d.ts +11 -0
  27. package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.d.ts.map +1 -0
  28. package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.js +43 -0
  29. package/lib/components/KeyboardAvoidingScrollView/native-driver.d.ts +34 -0
  30. package/lib/components/KeyboardAvoidingScrollView/native-driver.d.ts.map +1 -0
  31. package/lib/components/KeyboardAvoidingScrollView/native-driver.js +133 -0
  32. package/lib/components/TextField/TextField.d.ts +113 -0
  33. package/lib/components/TextField/TextField.d.ts.map +1 -0
  34. package/lib/components/TextField/TextField.jsx +418 -0
  35. package/lib/components/TextField/TextField.namespace.d.ts +2 -0
  36. package/lib/components/TextField/TextField.namespace.d.ts.map +1 -0
  37. package/lib/components/TextField/TextField.namespace.js +14 -0
  38. package/lib/components/TextField/context.d.ts +22 -0
  39. package/lib/components/TextField/context.d.ts.map +1 -0
  40. package/lib/components/TextField/context.js +7 -0
  41. package/lib/components/TextField/index.d.ts +4 -0
  42. package/lib/components/TextField/index.d.ts.map +1 -0
  43. package/lib/components/TextField/useTextFieldWithGraphemes.d.ts +24 -0
  44. package/lib/components/TextField/useTextFieldWithGraphemes.d.ts.map +1 -0
  45. package/lib/components/TextField/useTextFieldWithGraphemes.js +47 -0
  46. package/lib/components/index.d.ts +3 -0
  47. package/lib/components/index.d.ts.map +1 -1
  48. package/lib/index.js +7 -1
  49. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_grapheme_data.js +8 -0
  50. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_incb_data.js +14 -0
  51. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/core.js +68 -0
  52. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/grapheme.js +175 -0
  53. package/package.json +6 -4
  54. package/src/components/Field/Field.namespace.ts +19 -0
  55. package/src/components/Field/Field.test.tsx +81 -0
  56. package/src/components/Field/Field.tsx +252 -0
  57. package/src/components/Field/context.ts +26 -0
  58. package/src/components/Field/index.ts +21 -0
  59. package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.test.tsx +267 -0
  60. package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.tsx +299 -0
  61. package/src/components/KeyboardAvoidingScrollView/context.ts +24 -0
  62. package/src/components/KeyboardAvoidingScrollView/engine.test.ts +451 -0
  63. package/src/components/KeyboardAvoidingScrollView/engine.ts +417 -0
  64. package/src/components/KeyboardAvoidingScrollView/geometry.test.ts +315 -0
  65. package/src/components/KeyboardAvoidingScrollView/geometry.ts +194 -0
  66. package/src/components/KeyboardAvoidingScrollView/index.ts +5 -0
  67. package/src/components/KeyboardAvoidingScrollView/keyboard-event-source.test.ts +122 -0
  68. package/src/components/KeyboardAvoidingScrollView/keyboard-event-source.ts +75 -0
  69. package/src/components/KeyboardAvoidingScrollView/native-driver.test.ts +223 -0
  70. package/src/components/KeyboardAvoidingScrollView/native-driver.ts +248 -0
  71. package/src/components/TextField/TextField.namespace.ts +16 -0
  72. package/src/components/TextField/TextField.test.tsx +924 -0
  73. package/src/components/TextField/TextField.tsx +912 -0
  74. package/src/components/TextField/context.ts +33 -0
  75. package/src/components/TextField/index.ts +23 -0
  76. package/src/components/TextField/useTextFieldWithGraphemes.test.tsx +80 -0
  77. package/src/components/TextField/useTextFieldWithGraphemes.ts +78 -0
  78. package/src/components/index.ts +3 -0
@@ -0,0 +1,912 @@
1
+ import * as React from "@lynx-js/react";
2
+ import type { IntrinsicElements, NodesRef } from "@lynx-js/types";
3
+ import { textInput, type TextInputVariantProps } from "@seed-design/lynx-css/recipes/text-input";
4
+ import { textInput as textInputVars } from "@seed-design/lynx-css/vars/component";
5
+ import clsx from "clsx";
6
+
7
+ import type { LynxAccessibilityProps, LynxStyledElementProps, LynxTextRef } from "../../types";
8
+ import { createSlotRecipeContext } from "../../utils/create-slot-recipe-context";
9
+ import { useKeyboardAvoidanceActions } from "../KeyboardAvoidingScrollView/context";
10
+ import { InternalIcon, type InternalIconProps } from "../Icon/Icon";
11
+ import { useFieldContext } from "../Field/context";
12
+ import { NATIVE_TEXT_MAX_LENGTH_UNLIMITED, TextFieldContext } from "./context";
13
+
14
+ type LynxSystemInfo = { platform?: string };
15
+
16
+ declare const SystemInfo: LynxSystemInfo | undefined;
17
+
18
+ const ANDROID_TEXTAREA_DEFAULT_LINE_SPACING = "3.2px" as const;
19
+
20
+ function isAndroidRuntime(): boolean {
21
+ const globalSystemInfo = (globalThis as typeof globalThis & { SystemInfo?: LynxSystemInfo })
22
+ .SystemInfo;
23
+ const systemInfo =
24
+ globalSystemInfo ?? (typeof SystemInfo === "undefined" ? undefined : SystemInfo);
25
+
26
+ return systemInfo?.platform === "Android";
27
+ }
28
+
29
+ const { ClassNamesProvider, useClassNames } = createSlotRecipeContext(textInput);
30
+
31
+ ////////////////////////////////////////////////////////////////////////////////////
32
+
33
+ /**
34
+ * @platform Lynx
35
+ *
36
+ * native `<input>`과 `<textarea>`의 값 상태를 소유한다.
37
+ * Lynx native 입력은 value attribute를 제공하지 않으므로 하위 입력 슬롯이
38
+ * `setValue` UI method로 controlled value를 동기화한다.
39
+ */
40
+ export interface TextFieldRootProps
41
+ extends Omit<TextInputVariantProps, "focused">,
42
+ LynxStyledElementProps {
43
+ value?: string;
44
+ defaultValue?: string;
45
+ onValueChange?: (value: string) => void;
46
+ /** @internal `useTextFieldWithGraphemes`가 전달하는 네이티브 입력 사전 제한 값. */
47
+ nativeInsertionMaxLength?: number;
48
+ required?: boolean;
49
+ name?: string;
50
+ }
51
+
52
+ export const TextFieldRoot = React.forwardRef<NodesRef, TextFieldRootProps>(
53
+ (props, forwardedRef) => {
54
+ const [variantProps, otherProps] = textInput.splitVariantProps(props);
55
+ const {
56
+ children,
57
+ className,
58
+ value: controlledValue,
59
+ defaultValue = "",
60
+ onValueChange,
61
+ nativeInsertionMaxLength,
62
+ required: requiredProp,
63
+ name,
64
+ ...nativeProps
65
+ } = otherProps;
66
+ const fieldContext = useFieldContext({ strict: false });
67
+ const rootRef = React.useRef<NodesRef | null>(null);
68
+ const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue);
69
+ const [localFocused, setLocalFocused] = React.useState(false);
70
+ const isControlled = controlledValue !== undefined;
71
+ const value = isControlled ? controlledValue : uncontrolledValue;
72
+ const disabled = variantProps.disabled ?? fieldContext?.disabled ?? false;
73
+ const invalid = variantProps.invalid ?? fieldContext?.invalid ?? false;
74
+ const readOnly = variantProps.readOnly ?? fieldContext?.readOnly ?? false;
75
+ const required = requiredProp ?? fieldContext?.required ?? false;
76
+ const focused = fieldContext?.focused ?? localFocused;
77
+ const variant = variantProps.variant ?? "outline";
78
+ const size = variantProps.size ?? "large";
79
+ const valueRef = React.useRef(value);
80
+ const controlledRef = React.useRef(isControlled);
81
+ const onValueChangeRef = React.useRef(onValueChange);
82
+ const fieldContextRef = React.useRef(fieldContext);
83
+ valueRef.current = value;
84
+ controlledRef.current = isControlled;
85
+ onValueChangeRef.current = onValueChange;
86
+ fieldContextRef.current = fieldContext;
87
+ const classes = textInput({
88
+ ...variantProps,
89
+ variant,
90
+ size,
91
+ disabled,
92
+ focused: focused && !readOnly,
93
+ invalid,
94
+ readOnly,
95
+ });
96
+
97
+ const mergedRef = React.useCallback(
98
+ (node: NodesRef | null) => {
99
+ "background only";
100
+
101
+ rootRef.current = node;
102
+ if (typeof forwardedRef === "function") {
103
+ forwardedRef(node);
104
+ } else if (forwardedRef) {
105
+ forwardedRef.current = node;
106
+ }
107
+ },
108
+ [forwardedRef],
109
+ );
110
+
111
+ const setFocused = React.useCallback((nextFocused: boolean) => {
112
+ "background only";
113
+
114
+ setLocalFocused(nextFocused);
115
+ fieldContextRef.current?.setFocused(nextFocused);
116
+ }, []);
117
+
118
+ const setValue = React.useCallback((nextValue: string) => {
119
+ "background only";
120
+
121
+ if (!controlledRef.current) {
122
+ setUncontrolledValue(nextValue);
123
+ }
124
+ if (nextValue !== valueRef.current) {
125
+ onValueChangeRef.current?.(nextValue);
126
+ }
127
+ }, []);
128
+
129
+ const contextValue = React.useMemo(
130
+ () => ({
131
+ rootRef,
132
+ value,
133
+ controlled: isControlled,
134
+ nativeInsertionMaxLength,
135
+ disabled,
136
+ invalid,
137
+ readOnly,
138
+ required,
139
+ name,
140
+ focused,
141
+ setValue,
142
+ setFocused,
143
+ }),
144
+ [
145
+ disabled,
146
+ focused,
147
+ invalid,
148
+ name,
149
+ nativeInsertionMaxLength,
150
+ readOnly,
151
+ required,
152
+ setFocused,
153
+ setValue,
154
+ value,
155
+ isControlled,
156
+ ],
157
+ );
158
+
159
+ return (
160
+ <TextFieldContext.Provider value={contextValue}>
161
+ <ClassNamesProvider value={classes}>
162
+ <view ref={mergedRef} className={clsx(classes.root, className)} {...nativeProps}>
163
+ <view className={classes.baseStroke} accessibility-elements-hidden={true} />
164
+ <view className={classes.stroke} accessibility-elements-hidden={true} />
165
+ {children}
166
+ </view>
167
+ </ClassNamesProvider>
168
+ </TextFieldContext.Provider>
169
+ );
170
+ },
171
+ );
172
+ TextFieldRoot.displayName = "TextFieldRoot";
173
+
174
+ ////////////////////////////////////////////////////////////////////////////////////
175
+
176
+ type NativeInputProps = IntrinsicElements["input"];
177
+ type NativeTextareaProps = IntrinsicElements["textarea"];
178
+ type NativeInputEvent = Parameters<NonNullable<NativeInputProps["bindinput"]>>[0];
179
+ type NativeTextareaEvent = Parameters<NonNullable<NativeTextareaProps["bindinput"]>>[0];
180
+
181
+ interface NativeSelectionDetail {
182
+ selectionStart: number;
183
+ selectionEnd: number;
184
+ }
185
+
186
+ function resolveNativeMaxLength(
187
+ explicitMaxLength: number | undefined,
188
+ insertionMaxLength: number | undefined,
189
+ ): number | undefined {
190
+ if (explicitMaxLength === undefined) return insertionMaxLength;
191
+ if (insertionMaxLength === undefined) return explicitMaxLength;
192
+
193
+ return Math.min(explicitMaxLength, insertionMaxLength);
194
+ }
195
+
196
+ function useWasDefined(value: unknown): boolean {
197
+ const isDefined = value !== undefined;
198
+ const [wasDefined, setWasDefined] = React.useState(isDefined);
199
+
200
+ React.useEffect(() => {
201
+ if (isDefined) setWasDefined(true);
202
+ }, [isDefined]);
203
+
204
+ return wasDefined || isDefined;
205
+ }
206
+
207
+ function useResolvedNativeMaxLength(
208
+ explicitMaxLength: number | undefined,
209
+ insertionMaxLength: number | undefined,
210
+ ): number | undefined {
211
+ const wasManaged = useWasDefined(
212
+ explicitMaxLength !== undefined || insertionMaxLength !== undefined ? true : undefined,
213
+ );
214
+
215
+ if (!wasManaged) return undefined;
216
+
217
+ return resolveNativeMaxLength(
218
+ explicitMaxLength,
219
+ insertionMaxLength ?? NATIVE_TEXT_MAX_LENGTH_UNLIMITED,
220
+ );
221
+ }
222
+
223
+ interface UseNativeTextControlOptions {
224
+ forwardedRef: React.Ref<NodesRef>;
225
+ disabled?: boolean;
226
+ readOnly?: boolean;
227
+ bindinput?: (event: NativeInputEvent | NativeTextareaEvent) => void;
228
+ bindfocus?: NativeInputProps["bindfocus"] | NativeTextareaProps["bindfocus"];
229
+ bindblur?: NativeInputProps["bindblur"] | NativeTextareaProps["bindblur"];
230
+ }
231
+
232
+ function useNativeTextControl({
233
+ forwardedRef,
234
+ disabled: disabledProp,
235
+ readOnly: readOnlyProp,
236
+ bindinput,
237
+ bindfocus,
238
+ bindblur,
239
+ }: UseNativeTextControlOptions) {
240
+ const textFieldContext = React.useContext(TextFieldContext);
241
+ if (!textFieldContext) {
242
+ throw new Error("TextField input components must be rendered inside <TextField.Root>.");
243
+ }
244
+
245
+ const fieldContext = useFieldContext({ strict: false });
246
+ const keyboardAvoidance = useKeyboardAvoidanceActions();
247
+ const disabled = disabledProp ?? textFieldContext.disabled;
248
+ const readOnly = readOnlyProp ?? textFieldContext.readOnly;
249
+ const nativeRef = React.useRef<NodesRef | null>(null);
250
+ const initialNativeValueRef = React.useRef(textFieldContext.value);
251
+ const ownerRef = React.useRef<object>({});
252
+ const lastNativeValueRef = React.useRef<string | null>(initialNativeValueRef.current);
253
+ const committedValueRef = React.useRef(textFieldContext.value);
254
+ const controlledRef = React.useRef(textFieldContext.controlled);
255
+ const readOnlyRef = React.useRef(readOnly);
256
+ const isComposingRef = React.useRef(false);
257
+ const canApplyInsertionMaxLengthRef = React.useRef(true);
258
+ const reconciliationRevisionRef = React.useRef(0);
259
+ const [canApplyInsertionMaxLength, setCanApplyInsertionMaxLength] = React.useState(true);
260
+ const wasInsertionMaxLengthManaged = useWasDefined(textFieldContext.nativeInsertionMaxLength);
261
+
262
+ committedValueRef.current = textFieldContext.value;
263
+ controlledRef.current = textFieldContext.controlled;
264
+ readOnlyRef.current = readOnly;
265
+
266
+ const syncNativeValue = React.useCallback((node: NodesRef, value: string) => {
267
+ "background only";
268
+
269
+ if (lastNativeValueRef.current === value || typeof node.invoke !== "function") return;
270
+
271
+ lastNativeValueRef.current = value;
272
+
273
+ try {
274
+ node
275
+ .invoke({
276
+ method: "setValue",
277
+ params: { value },
278
+ fail() {
279
+ "background only";
280
+ if (lastNativeValueRef.current === value) {
281
+ lastNativeValueRef.current = null;
282
+ }
283
+ },
284
+ })
285
+ .exec();
286
+ } catch {
287
+ // Native node가 아직 commit되지 않았거나 UI method를 지원하지 않으면
288
+ // 다음 value commit에서 다시 동기화한다.
289
+ lastNativeValueRef.current = null;
290
+ }
291
+ }, []);
292
+
293
+ const mergedRef = React.useCallback(
294
+ (node: NodesRef | null) => {
295
+ "background only";
296
+
297
+ nativeRef.current = node;
298
+ if (typeof forwardedRef === "function") {
299
+ forwardedRef(node);
300
+ } else if (forwardedRef) {
301
+ forwardedRef.current = node;
302
+ }
303
+ },
304
+ [forwardedRef],
305
+ );
306
+
307
+ const updateEditingState = React.useCallback(
308
+ (selectionStart: number, selectionEnd: number, isComposing = isComposingRef.current) => {
309
+ "background only";
310
+
311
+ isComposingRef.current = isComposing;
312
+ const nextCanApplyInsertionMaxLength =
313
+ !isComposing && selectionStart >= 0 && selectionStart === selectionEnd;
314
+ if (canApplyInsertionMaxLengthRef.current === nextCanApplyInsertionMaxLength) return;
315
+
316
+ canApplyInsertionMaxLengthRef.current = nextCanApplyInsertionMaxLength;
317
+ setCanApplyInsertionMaxLength(nextCanApplyInsertionMaxLength);
318
+ },
319
+ [],
320
+ );
321
+
322
+ const reconcileControlledValue = React.useCallback(
323
+ (nativeValue: string) => {
324
+ "background only";
325
+
326
+ if (!controlledRef.current) return;
327
+
328
+ reconciliationRevisionRef.current += 1;
329
+ const revision = reconciliationRevisionRef.current;
330
+ const nodeAtInput = nativeRef.current;
331
+
332
+ void Promise.resolve()
333
+ .then(() => {
334
+ "background only";
335
+ // onValueChange가 microtask에서 부모 state를 갱신해도 그 commit을 먼저 처리한다.
336
+ })
337
+ .then(() => {
338
+ "background only";
339
+
340
+ if (
341
+ revision !== reconciliationRevisionRef.current ||
342
+ !controlledRef.current ||
343
+ readOnlyRef.current ||
344
+ nativeRef.current !== nodeAtInput
345
+ ) {
346
+ return;
347
+ }
348
+
349
+ const committedValue = committedValueRef.current;
350
+ if (committedValue === nativeValue) return;
351
+
352
+ const node = nativeRef.current;
353
+ if (node) {
354
+ syncNativeValue(node, committedValue);
355
+ }
356
+ });
357
+ },
358
+ [syncNativeValue],
359
+ );
360
+
361
+ React.useEffect(() => {
362
+ if (readOnly) return;
363
+ if (lastNativeValueRef.current === textFieldContext.value) return;
364
+
365
+ const node = nativeRef.current;
366
+ if (node) {
367
+ syncNativeValue(node, textFieldContext.value);
368
+ }
369
+ }, [readOnly, syncNativeValue, textFieldContext.value]);
370
+
371
+ React.useEffect(
372
+ () => () => {
373
+ keyboardAvoidance?.unregister(ownerRef.current);
374
+ reconciliationRevisionRef.current += 1;
375
+ },
376
+ [keyboardAvoidance],
377
+ );
378
+
379
+ const handleInput = React.useCallback(
380
+ (event: NativeInputEvent | NativeTextareaEvent) => {
381
+ "background only";
382
+
383
+ const { value: nextValue, selectionStart, selectionEnd, isComposing } = event.detail;
384
+ if (disabled || readOnly) {
385
+ lastNativeValueRef.current = nextValue;
386
+
387
+ if (nextValue !== committedValueRef.current) {
388
+ const node = nativeRef.current;
389
+ if (node) {
390
+ syncNativeValue(node, committedValueRef.current);
391
+ }
392
+ }
393
+ return;
394
+ }
395
+
396
+ updateEditingState(selectionStart, selectionEnd, isComposing === true);
397
+ lastNativeValueRef.current = nextValue;
398
+ textFieldContext.setValue(nextValue);
399
+ bindinput?.(event);
400
+ reconcileControlledValue(nextValue);
401
+ },
402
+ [
403
+ bindinput,
404
+ disabled,
405
+ readOnly,
406
+ reconcileControlledValue,
407
+ syncNativeValue,
408
+ textFieldContext.setValue,
409
+ updateEditingState,
410
+ ],
411
+ );
412
+
413
+ const handleSelectionChange = React.useCallback(
414
+ (detail: NativeSelectionDetail) => {
415
+ "background only";
416
+
417
+ updateEditingState(detail.selectionStart, detail.selectionEnd);
418
+ },
419
+ [updateEditingState],
420
+ );
421
+
422
+ const handleFocus = React.useCallback(
423
+ (event: Parameters<NonNullable<NativeInputProps["bindfocus"]>>[0]) => {
424
+ "background only";
425
+
426
+ textFieldContext.setFocused(true);
427
+ keyboardAvoidance?.focus({
428
+ owner: ownerRef.current,
429
+ nativeRef,
430
+ controlRef: textFieldContext.rootRef,
431
+ fieldRef: fieldContext?.rootRef,
432
+ enabled: !disabled && !readOnly,
433
+ });
434
+ bindfocus?.(event);
435
+ },
436
+ [
437
+ bindfocus,
438
+ disabled,
439
+ fieldContext?.rootRef,
440
+ keyboardAvoidance,
441
+ readOnly,
442
+ textFieldContext.rootRef,
443
+ textFieldContext.setFocused,
444
+ ],
445
+ );
446
+
447
+ const handleBlur = React.useCallback(
448
+ (event: Parameters<NonNullable<NativeInputProps["bindblur"]>>[0]) => {
449
+ "background only";
450
+
451
+ textFieldContext.setFocused(false);
452
+ keyboardAvoidance?.blur(ownerRef.current);
453
+ bindblur?.(event);
454
+ },
455
+ [bindblur, keyboardAvoidance, textFieldContext.setFocused],
456
+ );
457
+
458
+ const notifyLayoutChanged = React.useCallback(() => {
459
+ "background only";
460
+
461
+ keyboardAvoidance?.layoutChanged(ownerRef.current);
462
+ }, [keyboardAvoidance]);
463
+
464
+ const focusNativeControl = React.useCallback(() => {
465
+ "background only";
466
+
467
+ const node = nativeRef.current;
468
+ if (!node || typeof node.invoke !== "function") return;
469
+
470
+ try {
471
+ node.invoke({ method: "focus" }).exec();
472
+ } catch {
473
+ // Native node가 아직 commit되지 않았으면 실제 textarea 탭이 focus를 처리한다.
474
+ }
475
+ }, []);
476
+
477
+ return {
478
+ context: textFieldContext,
479
+ disabled,
480
+ readOnly,
481
+ defaultValueProps: { "default-value": initialNativeValueRef.current },
482
+ mergedRef,
483
+ handleInput,
484
+ handleFocus,
485
+ handleBlur,
486
+ notifyLayoutChanged,
487
+ focusNativeControl,
488
+ handleSelectionChange,
489
+ nativeInsertionMaxLength:
490
+ wasInsertionMaxLengthManaged &&
491
+ canApplyInsertionMaxLength &&
492
+ textFieldContext.nativeInsertionMaxLength !== undefined
493
+ ? textFieldContext.nativeInsertionMaxLength
494
+ : wasInsertionMaxLengthManaged
495
+ ? NATIVE_TEXT_MAX_LENGTH_UNLIMITED
496
+ : undefined,
497
+ };
498
+ }
499
+
500
+ interface NativeTextControlProps
501
+ extends Omit<LynxStyledElementProps, "children">,
502
+ LynxAccessibilityProps {
503
+ id?: NativeInputProps["id"];
504
+ name?: NativeInputProps["name"];
505
+ hidden?: NativeInputProps["hidden"];
506
+ flatten?: NativeInputProps["flatten"];
507
+ focusable?: NativeInputProps["focusable"];
508
+ bindlayoutchange?: NativeInputProps["bindlayoutchange"];
509
+ "main-thread:bindlayoutchange"?: NativeInputProps["main-thread:bindlayoutchange"];
510
+ }
511
+
512
+ type AndroidSetSoftInputMode = "unspecified" | "nothing" | "pan" | "resize";
513
+
514
+ function getAccessibilityProps(props: LynxAccessibilityProps): LynxAccessibilityProps {
515
+ return {
516
+ "accessibility-label": props["accessibility-label"],
517
+ "accessibility-traits": props["accessibility-traits"],
518
+ "accessibility-element": props["accessibility-element"],
519
+ "accessibility-value": props["accessibility-value"],
520
+ "accessibility-role-description": props["accessibility-role-description"],
521
+ "accessibility-elements-hidden": props["accessibility-elements-hidden"],
522
+ "accessibility-heading": props["accessibility-heading"],
523
+ "accessibility-actions": props["accessibility-actions"],
524
+ "accessibility-exclusive-focus": props["accessibility-exclusive-focus"],
525
+ "ios-platform-accessibility-id": props["ios-platform-accessibility-id"],
526
+ };
527
+ }
528
+
529
+ function getReadOnlyTextStyle({
530
+ style,
531
+ disabled,
532
+ placeholder,
533
+ multiline,
534
+ }: {
535
+ style: LynxStyledElementProps["style"];
536
+ disabled: boolean;
537
+ placeholder: boolean;
538
+ multiline: boolean;
539
+ }): LynxStyledElementProps["style"] {
540
+ return {
541
+ ...style,
542
+ ...(multiline ? { whiteSpace: "normal", wordBreak: "break-all" } : { alignSelf: "center" }),
543
+ ...(placeholder
544
+ ? {
545
+ color: disabled
546
+ ? textInputVars.base.disabled.placeholder.color
547
+ : textInputVars.base.enabled.placeholder.color,
548
+ }
549
+ : {}),
550
+ };
551
+ }
552
+
553
+ /**
554
+ * @platform Lynx
555
+ *
556
+ * `readOnly` 상태에서는 native focus·selection·편집 메뉴를 제거하기 위해 `<text>`로 렌더링한다.
557
+ * 이때 ref는 `<text>`를 가리키며 input 전용 UI method와 이벤트는 사용할 수 없다.
558
+ */
559
+ export interface TextFieldInputProps extends NativeTextControlProps {
560
+ placeholder?: NativeInputProps["placeholder"];
561
+ "confirm-type"?: NativeInputProps["confirm-type"];
562
+ maxlength?: NativeInputProps["maxlength"];
563
+ readonly?: NativeInputProps["readonly"];
564
+ disabled?: NativeInputProps["disabled"];
565
+ "show-soft-input-on-focus"?: NativeInputProps["show-soft-input-on-focus"];
566
+ "input-filter"?: NativeInputProps["input-filter"];
567
+ type?: NativeInputProps["type"];
568
+ "ios-auto-correct"?: NativeInputProps["ios-auto-correct"];
569
+ "ios-spell-check"?: NativeInputProps["ios-spell-check"];
570
+ "android-fullscreen-mode"?: NativeInputProps["android-fullscreen-mode"];
571
+ /** Android host window의 soft input mode를 지정한다. */
572
+ "android-set-soft-input-mode"?: AndroidSetSoftInputMode;
573
+ bindfocus?: NativeInputProps["bindfocus"];
574
+ bindblur?: NativeInputProps["bindblur"];
575
+ bindconfirm?: NativeInputProps["bindconfirm"];
576
+ bindinput?: NativeInputProps["bindinput"];
577
+ bindselection?: NativeInputProps["bindselection"];
578
+ }
579
+
580
+ export const TextFieldInput = React.forwardRef<NodesRef, TextFieldInputProps>((props, ref) => {
581
+ const classes = useClassNames();
582
+ const {
583
+ className,
584
+ disabled,
585
+ readonly,
586
+ name,
587
+ maxlength,
588
+ bindinput,
589
+ bindselection,
590
+ bindfocus,
591
+ bindblur,
592
+ "show-soft-input-on-focus": showSoftInputOnFocus,
593
+ "android-set-soft-input-mode": androidSetSoftInputMode,
594
+ ...nativeProps
595
+ } = props;
596
+ const control = useNativeTextControl({
597
+ forwardedRef: ref,
598
+ disabled,
599
+ readOnly: readonly,
600
+ bindinput,
601
+ bindfocus,
602
+ bindblur,
603
+ });
604
+ const resolvedMaxLength = useResolvedNativeMaxLength(maxlength, control.nativeInsertionMaxLength);
605
+ const handleSelection = React.useCallback<NonNullable<NativeInputProps["bindselection"]>>(
606
+ (event) => {
607
+ "background only";
608
+
609
+ control.handleSelectionChange(event.detail);
610
+ bindselection?.(event);
611
+ },
612
+ [bindselection, control.handleSelectionChange],
613
+ );
614
+
615
+ if (control.readOnly) {
616
+ const value = control.context.value;
617
+ const isPlaceholder = value === "";
618
+ const displayValue = isPlaceholder
619
+ ? props.placeholder
620
+ : props.type === "password"
621
+ ? Array.from(value, () => "•").join("")
622
+ : value;
623
+
624
+ return (
625
+ <text
626
+ ref={control.mergedRef}
627
+ id={props.id}
628
+ hidden={props.hidden}
629
+ flatten={props.flatten}
630
+ focusable={props.focusable}
631
+ bindlayoutchange={props.bindlayoutchange}
632
+ main-thread:bindlayoutchange={props["main-thread:bindlayoutchange"]}
633
+ className={clsx(classes.value, className)}
634
+ style={getReadOnlyTextStyle({
635
+ style: props.style,
636
+ disabled: control.disabled,
637
+ placeholder: isPlaceholder,
638
+ multiline: false,
639
+ })}
640
+ {...getAccessibilityProps(props)}
641
+ >
642
+ {displayValue}
643
+ </text>
644
+ );
645
+ }
646
+
647
+ return (
648
+ <input
649
+ {...control.defaultValueProps}
650
+ ref={control.mergedRef}
651
+ className={clsx(classes.value, className)}
652
+ disabled={control.disabled}
653
+ readonly={control.readOnly}
654
+ show-soft-input-on-focus={control.readOnly ? false : showSoftInputOnFocus}
655
+ android-set-soft-input-mode={androidSetSoftInputMode}
656
+ name={name ?? control.context.name}
657
+ maxlength={resolvedMaxLength}
658
+ bindinput={control.handleInput}
659
+ bindselection={handleSelection}
660
+ bindfocus={control.handleFocus}
661
+ bindblur={control.handleBlur}
662
+ {...nativeProps}
663
+ />
664
+ );
665
+ });
666
+ TextFieldInput.displayName = "TextFieldInput";
667
+
668
+ /**
669
+ * @platform Lynx
670
+ *
671
+ * `readOnly` 상태에서는 native focus·selection·편집 메뉴를 제거하기 위해 `<text>`로 렌더링한다.
672
+ * 이때 ref는 `<text>`를 가리키며 textarea 전용 UI method와 이벤트는 사용할 수 없다.
673
+ */
674
+ export interface TextFieldTextareaProps extends NativeTextControlProps {
675
+ /** 내용에 맞춰 높이를 자동으로 조절한다. @defaultValue true */
676
+ autoresize?: boolean;
677
+ placeholder?: NativeTextareaProps["placeholder"];
678
+ "confirm-type"?: NativeTextareaProps["confirm-type"];
679
+ maxlength?: NativeTextareaProps["maxlength"];
680
+ maxlines?: NativeTextareaProps["maxlines"];
681
+ bounces?: NativeTextareaProps["bounces"];
682
+ /**
683
+ * native 줄 간격을 지정한다. 생략하면 Android에서 SEED 기본 typography를 맞추기 위해
684
+ * `3.2px`를 적용하고 iOS에는 전달하지 않는다. Android 기본 보정은 `0`으로 해제할 수 있다.
685
+ */
686
+ "line-spacing"?: NativeTextareaProps["line-spacing"];
687
+ readonly?: NativeTextareaProps["readonly"];
688
+ disabled?: NativeTextareaProps["disabled"];
689
+ "show-soft-input-on-focus"?: NativeTextareaProps["show-soft-input-on-focus"];
690
+ "input-filter"?: NativeTextareaProps["input-filter"];
691
+ "enable-scroll-bar"?: NativeTextareaProps["enable-scroll-bar"];
692
+ type?: NativeTextareaProps["type"];
693
+ "ios-auto-correct"?: NativeTextareaProps["ios-auto-correct"];
694
+ "ios-spell-check"?: NativeTextareaProps["ios-spell-check"];
695
+ /** Android의 fullscreen extract input을 활성화한다. @defaultValue false */
696
+ "android-fullscreen-mode"?: NativeTextareaProps["android-fullscreen-mode"];
697
+ /** Android host window의 soft input mode를 지정한다. */
698
+ "android-set-soft-input-mode"?: AndroidSetSoftInputMode;
699
+ bindfocus?: NativeTextareaProps["bindfocus"];
700
+ bindblur?: NativeTextareaProps["bindblur"];
701
+ bindconfirm?: NativeTextareaProps["bindconfirm"];
702
+ bindinput?: NativeTextareaProps["bindinput"];
703
+ bindselection?: NativeTextareaProps["bindselection"];
704
+ }
705
+
706
+ export const TextFieldTextarea = React.forwardRef<NodesRef, TextFieldTextareaProps>(
707
+ (props, ref) => {
708
+ const classes = useClassNames();
709
+ const {
710
+ className,
711
+ disabled,
712
+ readonly,
713
+ name,
714
+ maxlength,
715
+ bindinput,
716
+ bindselection,
717
+ bindfocus,
718
+ bindblur,
719
+ bindlayoutchange,
720
+ "show-soft-input-on-focus": showSoftInputOnFocus,
721
+ bounces,
722
+ "line-spacing": lineSpacing,
723
+ "android-fullscreen-mode": androidFullscreenMode,
724
+ "android-set-soft-input-mode": androidSetSoftInputMode,
725
+ autoresize = true,
726
+ ...nativeProps
727
+ } = props;
728
+ const control = useNativeTextControl({
729
+ forwardedRef: ref,
730
+ disabled,
731
+ readOnly: readonly,
732
+ bindinput,
733
+ bindfocus,
734
+ bindblur,
735
+ });
736
+ const resolvedMaxLength = useResolvedNativeMaxLength(
737
+ maxlength,
738
+ control.nativeInsertionMaxLength,
739
+ );
740
+ const isAndroid = isAndroidRuntime();
741
+ // Android native textarea는 CSS line-height를 무시한다. 실기기에서 관측한
742
+ // native font metrics와 SEED line box의 차이를 line-spacing으로 보정한다.
743
+ // 명시적인 값(0 포함)은 내부 기본값보다 우선한다.
744
+ const resolvedLineSpacing =
745
+ lineSpacing !== undefined
746
+ ? lineSpacing
747
+ : isAndroid
748
+ ? ANDROID_TEXTAREA_DEFAULT_LINE_SPACING
749
+ : undefined;
750
+ const handleSelection = React.useCallback<NonNullable<NativeTextareaProps["bindselection"]>>(
751
+ (event) => {
752
+ "background only";
753
+
754
+ control.handleSelectionChange(event.detail);
755
+ bindselection?.(event);
756
+ },
757
+ [bindselection, control.handleSelectionChange],
758
+ );
759
+ const handleTextareaRootTap = React.useCallback<
760
+ NonNullable<IntrinsicElements["view"]["bindtap"]>
761
+ >(
762
+ (event) => {
763
+ "background only";
764
+
765
+ if (event.target.uid !== event.currentTarget.uid) return;
766
+ control.focusNativeControl();
767
+ },
768
+ [control.focusNativeControl],
769
+ );
770
+
771
+ if (control.readOnly) {
772
+ const value = control.context.value;
773
+ const isPlaceholder = value === "";
774
+
775
+ return (
776
+ <text
777
+ ref={control.mergedRef}
778
+ id={props.id}
779
+ hidden={props.hidden}
780
+ flatten={props.flatten}
781
+ focusable={props.focusable}
782
+ bindlayoutchange={props.bindlayoutchange}
783
+ main-thread:bindlayoutchange={props["main-thread:bindlayoutchange"]}
784
+ className={clsx(classes.value, classes.textareaValue, classes.textareaFixed, className)}
785
+ style={getReadOnlyTextStyle({
786
+ style: props.style,
787
+ disabled: control.disabled,
788
+ placeholder: isPlaceholder,
789
+ multiline: true,
790
+ })}
791
+ {...getAccessibilityProps(props)}
792
+ >
793
+ {isPlaceholder ? props.placeholder : value}
794
+ </text>
795
+ );
796
+ }
797
+
798
+ // Android EditText는 자체 canvas 경계 안에서 text와 scroll offset을 먼저 자른다.
799
+ // autoresize 시 세로 padding을 wrapper가 아니라 native가 소유해야 첫 줄 여유가 유효하다.
800
+ const textarea = (
801
+ <textarea
802
+ {...control.defaultValueProps}
803
+ ref={control.mergedRef}
804
+ className={clsx(
805
+ classes.value,
806
+ classes.textareaValue,
807
+ autoresize ? classes.textareaNativeAutoresize : classes.textareaFixed,
808
+ autoresize && isAndroid && classes.textareaAndroidAutoresize,
809
+ className,
810
+ )}
811
+ disabled={control.disabled}
812
+ readonly={control.readOnly}
813
+ show-soft-input-on-focus={control.readOnly ? false : showSoftInputOnFocus}
814
+ bounces={bounces ?? (autoresize ? false : undefined)}
815
+ line-spacing={resolvedLineSpacing}
816
+ android-fullscreen-mode={androidFullscreenMode ?? false}
817
+ android-set-soft-input-mode={androidSetSoftInputMode}
818
+ name={name ?? control.context.name}
819
+ maxlength={resolvedMaxLength}
820
+ bindinput={control.handleInput}
821
+ bindselection={handleSelection}
822
+ bindfocus={control.handleFocus}
823
+ bindblur={control.handleBlur}
824
+ bindlayoutchange={bindlayoutchange}
825
+ {...nativeProps}
826
+ />
827
+ );
828
+
829
+ if (!autoresize) return textarea;
830
+
831
+ return (
832
+ <view
833
+ className={clsx(classes.textareaRoot, !isAndroid && classes.textareaAutoresizeRoot)}
834
+ bindtap={handleTextareaRootTap}
835
+ bindlayoutchange={control.notifyLayoutChanged}
836
+ >
837
+ {textarea}
838
+ </view>
839
+ );
840
+ },
841
+ );
842
+ TextFieldTextarea.displayName = "TextFieldTextarea";
843
+
844
+ ////////////////////////////////////////////////////////////////////////////////////
845
+
846
+ export interface TextFieldPrefixIconProps extends InternalIconProps {}
847
+
848
+ export const TextFieldPrefixIcon = React.forwardRef<unknown, TextFieldPrefixIconProps>(
849
+ (props, ref) => {
850
+ const classes = useClassNames();
851
+ const { className, ...otherProps } = props;
852
+
853
+ return (
854
+ <InternalIcon ref={ref} className={clsx(classes.prefixIcon, className)} {...otherProps} />
855
+ );
856
+ },
857
+ );
858
+ TextFieldPrefixIcon.displayName = "TextFieldPrefixIcon";
859
+
860
+ export interface TextFieldPrefixTextProps extends LynxStyledElementProps {}
861
+
862
+ export const TextFieldPrefixText = React.forwardRef<unknown, TextFieldPrefixTextProps>(
863
+ (props, ref) => {
864
+ const classes = useClassNames();
865
+ const { children, className, ...nativeProps } = props;
866
+
867
+ return (
868
+ <text
869
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
870
+ className={clsx(classes.prefixText, className)}
871
+ {...nativeProps}
872
+ >
873
+ {children}
874
+ </text>
875
+ );
876
+ },
877
+ );
878
+ TextFieldPrefixText.displayName = "TextFieldPrefixText";
879
+
880
+ export interface TextFieldSuffixIconProps extends InternalIconProps {}
881
+
882
+ export const TextFieldSuffixIcon = React.forwardRef<unknown, TextFieldSuffixIconProps>(
883
+ (props, ref) => {
884
+ const classes = useClassNames();
885
+ const { className, ...otherProps } = props;
886
+
887
+ return (
888
+ <InternalIcon ref={ref} className={clsx(classes.suffixIcon, className)} {...otherProps} />
889
+ );
890
+ },
891
+ );
892
+ TextFieldSuffixIcon.displayName = "TextFieldSuffixIcon";
893
+
894
+ export interface TextFieldSuffixTextProps extends LynxStyledElementProps {}
895
+
896
+ export const TextFieldSuffixText = React.forwardRef<unknown, TextFieldSuffixTextProps>(
897
+ (props, ref) => {
898
+ const classes = useClassNames();
899
+ const { children, className, ...nativeProps } = props;
900
+
901
+ return (
902
+ <text
903
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
904
+ className={clsx(classes.suffixText, className)}
905
+ {...nativeProps}
906
+ >
907
+ {children}
908
+ </text>
909
+ );
910
+ },
911
+ );
912
+ TextFieldSuffixText.displayName = "TextFieldSuffixText";