@seed-design/lynx-react 0.3.1 → 0.4.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.
Files changed (91) 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/ProgressCircle/ProgressCircle.d.ts.map +1 -1
  33. package/lib/components/ProgressCircle/ProgressCircle.jsx +36 -5
  34. package/lib/components/Stack/Stack.d.ts.map +1 -1
  35. package/lib/components/Stack/Stack.jsx +10 -1
  36. package/lib/components/TagGroup/TagGroup.d.ts +2 -1
  37. package/lib/components/TagGroup/TagGroup.d.ts.map +1 -1
  38. package/lib/components/TagGroup/TagGroup.jsx +2 -1
  39. package/lib/components/TextField/TextField.d.ts +131 -0
  40. package/lib/components/TextField/TextField.d.ts.map +1 -0
  41. package/lib/components/TextField/TextField.jsx +434 -0
  42. package/lib/components/TextField/TextField.namespace.d.ts +2 -0
  43. package/lib/components/TextField/TextField.namespace.d.ts.map +1 -0
  44. package/lib/components/TextField/TextField.namespace.js +14 -0
  45. package/lib/components/TextField/context.d.ts +22 -0
  46. package/lib/components/TextField/context.d.ts.map +1 -0
  47. package/lib/components/TextField/context.js +7 -0
  48. package/lib/components/TextField/index.d.ts +4 -0
  49. package/lib/components/TextField/index.d.ts.map +1 -0
  50. package/lib/components/TextField/useTextFieldWithGraphemes.d.ts +24 -0
  51. package/lib/components/TextField/useTextFieldWithGraphemes.d.ts.map +1 -0
  52. package/lib/components/TextField/useTextFieldWithGraphemes.js +47 -0
  53. package/lib/components/index.d.ts +3 -0
  54. package/lib/components/index.d.ts.map +1 -1
  55. package/lib/index.js +7 -1
  56. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_grapheme_data.js +8 -0
  57. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_incb_data.js +14 -0
  58. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/core.js +68 -0
  59. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/grapheme.js +175 -0
  60. package/package.json +7 -5
  61. package/src/components/Field/Field.namespace.ts +19 -0
  62. package/src/components/Field/Field.test.tsx +81 -0
  63. package/src/components/Field/Field.tsx +252 -0
  64. package/src/components/Field/context.ts +26 -0
  65. package/src/components/Field/index.ts +21 -0
  66. package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.test.tsx +267 -0
  67. package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.tsx +299 -0
  68. package/src/components/KeyboardAvoidingScrollView/context.ts +24 -0
  69. package/src/components/KeyboardAvoidingScrollView/engine.test.ts +451 -0
  70. package/src/components/KeyboardAvoidingScrollView/engine.ts +417 -0
  71. package/src/components/KeyboardAvoidingScrollView/geometry.test.ts +315 -0
  72. package/src/components/KeyboardAvoidingScrollView/geometry.ts +194 -0
  73. package/src/components/KeyboardAvoidingScrollView/index.ts +5 -0
  74. package/src/components/KeyboardAvoidingScrollView/keyboard-event-source.test.ts +122 -0
  75. package/src/components/KeyboardAvoidingScrollView/keyboard-event-source.ts +75 -0
  76. package/src/components/KeyboardAvoidingScrollView/native-driver.test.ts +223 -0
  77. package/src/components/KeyboardAvoidingScrollView/native-driver.ts +248 -0
  78. package/src/components/ProgressCircle/ProgressCircle.test.tsx +58 -0
  79. package/src/components/ProgressCircle/ProgressCircle.tsx +42 -5
  80. package/src/components/Stack/Stack.test.tsx +67 -0
  81. package/src/components/Stack/Stack.tsx +15 -1
  82. package/src/components/TagGroup/TagGroup.test.tsx +73 -0
  83. package/src/components/TagGroup/TagGroup.tsx +4 -2
  84. package/src/components/TextField/TextField.namespace.ts +16 -0
  85. package/src/components/TextField/TextField.test.tsx +1052 -0
  86. package/src/components/TextField/TextField.tsx +954 -0
  87. package/src/components/TextField/context.ts +33 -0
  88. package/src/components/TextField/index.ts +23 -0
  89. package/src/components/TextField/useTextFieldWithGraphemes.test.tsx +80 -0
  90. package/src/components/TextField/useTextFieldWithGraphemes.ts +78 -0
  91. package/src/components/index.ts +3 -0
@@ -0,0 +1,21 @@
1
+ export {
2
+ FieldCharacterCount,
3
+ FieldDescription,
4
+ FieldErrorMessage,
5
+ FieldFooter,
6
+ FieldHeader,
7
+ FieldIndicatorText,
8
+ FieldLabel,
9
+ FieldRequiredIndicator,
10
+ FieldRoot,
11
+ type FieldCharacterCountProps,
12
+ type FieldDescriptionProps,
13
+ type FieldErrorMessageProps,
14
+ type FieldFooterProps,
15
+ type FieldHeaderProps,
16
+ type FieldIndicatorTextProps,
17
+ type FieldLabelProps,
18
+ type FieldRootProps,
19
+ } from "./Field";
20
+
21
+ export * as Field from "./Field.namespace";
@@ -0,0 +1,267 @@
1
+ import "@testing-library/jest-dom";
2
+ import { act, fireEvent, render } from "@lynx-js/react/testing-library";
3
+ import type { NodesRef } from "@lynx-js/types";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ interface MockEngineOptions {
7
+ driver: unknown;
8
+ scheduler: unknown;
9
+ getScrollNode(): unknown;
10
+ getSpacerNode(): unknown;
11
+ getKeyboardGap(): number;
12
+ getToolbarHeight(): number;
13
+ getSmooth(): boolean;
14
+ }
15
+
16
+ const mocks = vi.hoisted(() => {
17
+ const engine = {
18
+ focus: vi.fn(),
19
+ blur: vi.fn(),
20
+ layoutChanged: vi.fn(),
21
+ unregister: vi.fn(),
22
+ keyboardChanged: vi.fn(),
23
+ viewportChanged: vi.fn(),
24
+ userScrollStarted: vi.fn(),
25
+ userScrollEnded: vi.fn(),
26
+ dispose: vi.fn(),
27
+ };
28
+ const unsubscribe = vi.fn();
29
+
30
+ return {
31
+ engine,
32
+ createEngine: vi.fn((_options: MockEngineOptions) => engine),
33
+ nativeDriver: {},
34
+ keyboardSource: {
35
+ listener: null as ((state: { visible: boolean; height: number }) => void) | null,
36
+ subscribe: vi.fn(),
37
+ unsubscribe,
38
+ },
39
+ };
40
+ });
41
+
42
+ vi.mock("./engine", () => ({
43
+ createKeyboardAvoidingEngine: mocks.createEngine,
44
+ }));
45
+
46
+ vi.mock("./native-driver", () => ({
47
+ lynxKeyboardAvoidingNativeDriver: mocks.nativeDriver,
48
+ }));
49
+
50
+ vi.mock("./keyboard-event-source", () => ({
51
+ lynxKeyboardEventSource: {
52
+ subscribe: mocks.keyboardSource.subscribe,
53
+ },
54
+ }));
55
+
56
+ import { useEffect } from "@lynx-js/react";
57
+
58
+ import { useKeyboardAvoidanceActions, type KeyboardAvoidanceRegistration } from "./context";
59
+ import {
60
+ KeyboardAvoidingScrollView,
61
+ type KeyboardAvoidingScrollViewProps,
62
+ } from "./KeyboardAvoidingScrollView";
63
+
64
+ function ContextConsumer({ registration }: { registration: KeyboardAvoidanceRegistration }) {
65
+ const actions = useKeyboardAvoidanceActions();
66
+
67
+ useEffect(() => {
68
+ if (!actions) {
69
+ throw new Error("KeyboardAvoidingScrollView context가 필요합니다.");
70
+ }
71
+
72
+ actions.focus(registration);
73
+ actions.layoutChanged(registration.owner);
74
+
75
+ return () => {
76
+ actions.blur(registration.owner);
77
+ actions.unregister(registration.owner);
78
+ };
79
+ }, [actions, registration]);
80
+
81
+ return <view />;
82
+ }
83
+
84
+ function getScrollView(container: HTMLElement): Element {
85
+ const scrollView = container.querySelector("scroll-view");
86
+ if (!scrollView) {
87
+ throw new Error("scroll-view가 렌더되어야 합니다.");
88
+ }
89
+ return scrollView;
90
+ }
91
+
92
+ function getEventTargetRef(ref: { current: NodesRef | null }): HTMLElement {
93
+ if (!ref.current) {
94
+ throw new Error("scroll-view NodesRef가 연결되어야 합니다.");
95
+ }
96
+ return ref.current as unknown as HTMLElement;
97
+ }
98
+
99
+ describe("KeyboardAvoidingScrollView", () => {
100
+ beforeEach(() => {
101
+ vi.clearAllMocks();
102
+ mocks.keyboardSource.listener = null;
103
+ mocks.keyboardSource.subscribe.mockImplementation((listener) => {
104
+ mocks.keyboardSource.listener = listener;
105
+ return mocks.keyboardSource.unsubscribe;
106
+ });
107
+ });
108
+
109
+ it("renders a forced vertical scroll-view and an internal native spacer", () => {
110
+ const conflictingNativeProps = {
111
+ flatten: true,
112
+ "scroll-orientation": "horizontal",
113
+ } as unknown as KeyboardAvoidingScrollViewProps;
114
+ const { container, getByText } = render(
115
+ <KeyboardAvoidingScrollView {...conflictingNativeProps} id="form" className="custom-scroll">
116
+ <text>Form content</text>
117
+ </KeyboardAvoidingScrollView>,
118
+ );
119
+
120
+ const scrollView = getScrollView(container);
121
+ const spacer = scrollView.lastElementChild;
122
+
123
+ expect(scrollView).toHaveAttribute("scroll-orientation", "vertical");
124
+ expect(scrollView).toHaveAttribute("id", "form");
125
+ expect(scrollView).toHaveClass("custom-scroll");
126
+ expect(scrollView).toContainElement(getByText("Form content") as HTMLElement);
127
+ expect(spacer?.tagName.toLowerCase()).toBe("view");
128
+ expect(spacer).toHaveAttribute("accessibility-elements-hidden", "true");
129
+ });
130
+
131
+ it("composes viewport and user-scroll event handlers", () => {
132
+ const bindlayoutchange = vi.fn();
133
+ const bindtouchstart = vi.fn();
134
+ const bindtouchend = vi.fn();
135
+ const bindtouchcancel = vi.fn();
136
+ const bindscroll = vi.fn();
137
+ const bindscrollend = vi.fn();
138
+ const scrollRef = { current: null as NodesRef | null };
139
+ render(
140
+ <KeyboardAvoidingScrollView
141
+ ref={scrollRef}
142
+ bindlayoutchange={bindlayoutchange}
143
+ bindtouchstart={bindtouchstart}
144
+ bindtouchend={bindtouchend}
145
+ bindtouchcancel={bindtouchcancel}
146
+ bindscroll={bindscroll}
147
+ bindscrollend={bindscrollend}
148
+ />,
149
+ );
150
+ const scrollView = getEventTargetRef(scrollRef);
151
+
152
+ act(() => {
153
+ fireEvent.layoutchange(scrollView, {});
154
+ fireEvent.touchstart(scrollView, {});
155
+ fireEvent.scroll(scrollView, {});
156
+ fireEvent.touchend(scrollView, {});
157
+ });
158
+
159
+ expect(bindlayoutchange).toHaveBeenCalledTimes(1);
160
+ expect(mocks.engine.viewportChanged).toHaveBeenCalledTimes(1);
161
+ expect(bindtouchstart).toHaveBeenCalledTimes(1);
162
+ expect(mocks.engine.userScrollStarted).toHaveBeenCalledTimes(1);
163
+ expect(bindscroll).toHaveBeenCalledTimes(1);
164
+ expect(bindtouchend).toHaveBeenCalledTimes(1);
165
+ expect(mocks.engine.userScrollEnded).not.toHaveBeenCalled();
166
+
167
+ act(() => {
168
+ fireEvent.scrollend(scrollView, {});
169
+ fireEvent.touchstart(scrollView, {});
170
+ fireEvent.touchcancel(scrollView, {});
171
+ });
172
+
173
+ expect(bindscrollend).toHaveBeenCalledTimes(1);
174
+ expect(bindtouchcancel).toHaveBeenCalledTimes(1);
175
+ expect(mocks.engine.userScrollStarted).toHaveBeenCalledTimes(2);
176
+ expect(mocks.engine.userScrollEnded).toHaveBeenCalledTimes(2);
177
+ });
178
+
179
+ it("ends a touch that did not scroll without waiting for scrollend", () => {
180
+ const scrollRef = { current: null as NodesRef | null };
181
+ render(<KeyboardAvoidingScrollView ref={scrollRef} />);
182
+ const scrollView = getEventTargetRef(scrollRef);
183
+
184
+ act(() => {
185
+ fireEvent.touchstart(scrollView, {});
186
+ fireEvent.touchend(scrollView, {});
187
+ });
188
+
189
+ expect(mocks.engine.userScrollStarted).toHaveBeenCalledTimes(1);
190
+ expect(mocks.engine.userScrollEnded).toHaveBeenCalledTimes(1);
191
+ });
192
+
193
+ it("updates engine state before invoking the user handler", () => {
194
+ const bindlayoutchange = vi.fn();
195
+ const scrollRef = { current: null as NodesRef | null };
196
+ render(<KeyboardAvoidingScrollView ref={scrollRef} bindlayoutchange={bindlayoutchange} />);
197
+ const scrollView = getEventTargetRef(scrollRef);
198
+
199
+ fireEvent.layoutchange(scrollView, {});
200
+
201
+ expect(bindlayoutchange).toHaveBeenCalledTimes(1);
202
+ expect(mocks.engine.viewportChanged).toHaveBeenCalledTimes(1);
203
+ expect(mocks.engine.viewportChanged.mock.invocationCallOrder[0]).toBeLessThan(
204
+ bindlayoutchange.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
205
+ );
206
+ });
207
+
208
+ it("keeps engine options live without recreating the engine", () => {
209
+ const { rerender } = render(
210
+ <KeyboardAvoidingScrollView keyboardGap={32} scrollBehavior="smooth" />,
211
+ );
212
+ const options = mocks.createEngine.mock.calls[0]?.[0];
213
+
214
+ expect(options?.driver).toBe(mocks.nativeDriver);
215
+ expect(options?.getKeyboardGap()).toBe(32);
216
+ expect(options?.getToolbarHeight()).toBe(0);
217
+ expect(options?.getSmooth()).toBe(true);
218
+
219
+ rerender(<KeyboardAvoidingScrollView keyboardGap={-8} scrollBehavior="instant" />);
220
+
221
+ expect(mocks.createEngine).toHaveBeenCalledTimes(1);
222
+ expect(options?.getKeyboardGap()).toBe(-8);
223
+ expect(options?.getSmooth()).toBe(false);
224
+ expect(mocks.engine.viewportChanged).toHaveBeenCalledTimes(1);
225
+ });
226
+
227
+ it("forwards private registration actions through a stable context", () => {
228
+ const registration: KeyboardAvoidanceRegistration = {
229
+ owner: {},
230
+ nativeRef: { current: null },
231
+ };
232
+ const { rerender, unmount } = render(
233
+ <KeyboardAvoidingScrollView keyboardGap={24}>
234
+ <ContextConsumer registration={registration} />
235
+ </KeyboardAvoidingScrollView>,
236
+ );
237
+
238
+ expect(mocks.engine.focus).toHaveBeenCalledWith(registration);
239
+ expect(mocks.engine.layoutChanged).toHaveBeenCalledWith(registration.owner);
240
+
241
+ rerender(
242
+ <KeyboardAvoidingScrollView keyboardGap={48}>
243
+ <ContextConsumer registration={registration} />
244
+ </KeyboardAvoidingScrollView>,
245
+ );
246
+ expect(mocks.engine.focus).toHaveBeenCalledTimes(1);
247
+
248
+ unmount();
249
+ expect(mocks.engine.blur).toHaveBeenCalledWith(registration.owner);
250
+ expect(mocks.engine.unregister).toHaveBeenCalledWith(registration.owner);
251
+ });
252
+
253
+ it("subscribes keyboard state and cleans up the shared source and engine", () => {
254
+ const { unmount } = render(<KeyboardAvoidingScrollView />);
255
+
256
+ expect(mocks.keyboardSource.subscribe).toHaveBeenCalledTimes(1);
257
+
258
+ act(() => {
259
+ mocks.keyboardSource.listener?.({ visible: true, height: 320 });
260
+ });
261
+ expect(mocks.engine.keyboardChanged).toHaveBeenCalledWith({ visible: true, height: 320 });
262
+
263
+ unmount();
264
+ expect(mocks.keyboardSource.unsubscribe).toHaveBeenCalledTimes(1);
265
+ expect(mocks.engine.dispose).toHaveBeenCalledTimes(1);
266
+ });
267
+ });
@@ -0,0 +1,299 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ type ForwardRefExoticComponent,
8
+ type PropsWithoutRef,
9
+ type RefAttributes,
10
+ } from "@lynx-js/react";
11
+ import type { IntrinsicElements, NodesRef } from "@lynx-js/types";
12
+
13
+ import type {
14
+ LynxAccessibilityProps,
15
+ LynxStyledElementProps,
16
+ LynxTouchProps,
17
+ LynxViewProps,
18
+ LynxViewRef,
19
+ } from "../../types";
20
+ import { KeyboardAvoidanceActionsContext, type KeyboardAvoidanceActions } from "./context";
21
+ import { createKeyboardAvoidingEngine, type KeyboardAvoidingScheduler } from "./engine";
22
+ import { lynxKeyboardEventSource } from "./keyboard-event-source";
23
+ import { lynxKeyboardAvoidingNativeDriver } from "./native-driver";
24
+
25
+ type NativeScrollViewProps = IntrinsicElements["scroll-view"];
26
+ type LayoutChangeHandler = NonNullable<NativeScrollViewProps["bindlayoutchange"]>;
27
+ type ScrollHandler = NonNullable<NativeScrollViewProps["bindscroll"]>;
28
+ type ScrollEndHandler = NonNullable<NativeScrollViewProps["bindscrollend"]>;
29
+ type TouchStartHandler = NonNullable<NativeScrollViewProps["bindtouchstart"]>;
30
+ type TouchEndHandler = NonNullable<NativeScrollViewProps["bindtouchend"]>;
31
+ type TouchCancelHandler = NonNullable<NativeScrollViewProps["bindtouchcancel"]>;
32
+ type LynxForwardRefComponent<T, P> = ForwardRefExoticComponent<
33
+ PropsWithoutRef<P> & RefAttributes<T>
34
+ >;
35
+
36
+ export type KeyboardAvoidingScrollBehavior = "smooth" | "instant";
37
+
38
+ const lynxKeyboardAvoidingScheduler: KeyboardAvoidingScheduler = {
39
+ scheduleFrame(callback) {
40
+ "background only";
41
+
42
+ const frame = requestAnimationFrame(() => {
43
+ "background only";
44
+ void callback();
45
+ });
46
+
47
+ return () => {
48
+ "background only";
49
+ cancelAnimationFrame(frame);
50
+ };
51
+ },
52
+ scheduleTimer(callback, delayMs) {
53
+ "background only";
54
+
55
+ const timer = setTimeout(() => {
56
+ "background only";
57
+ void callback();
58
+ }, delayMs);
59
+
60
+ return () => {
61
+ "background only";
62
+ clearTimeout(timer);
63
+ };
64
+ },
65
+ };
66
+
67
+ const spacerProps = {
68
+ "accessibility-elements-hidden": true,
69
+ flatten: false,
70
+ } satisfies LynxViewProps;
71
+
72
+ /**
73
+ * @platform Lynx
74
+ *
75
+ * 세로 `<scroll-view>` 안의 활성 입력 요소가 키보드에 가려지지 않도록 한다.
76
+ * `TextField.Input`과 `TextField.Textarea`는 focus 시 자동으로 이 컴포넌트에 등록된다.
77
+ *
78
+ * 현재 미지원 기능:
79
+ * - 가로 스크롤과 중첩 스크롤
80
+ * - 키보드 툴바 높이 반영
81
+ * - 큰 Textarea의 caret 단위 회피
82
+ */
83
+ export interface KeyboardAvoidingScrollViewProps
84
+ extends LynxStyledElementProps,
85
+ LynxAccessibilityProps,
86
+ LynxTouchProps {
87
+ id?: NativeScrollViewProps["id"];
88
+ hidden?: NativeScrollViewProps["hidden"];
89
+ focusable?: NativeScrollViewProps["focusable"];
90
+ bounces?: NativeScrollViewProps["bounces"];
91
+ "enable-scroll"?: NativeScrollViewProps["enable-scroll"];
92
+ "scroll-bar-enable"?: NativeScrollViewProps["scroll-bar-enable"];
93
+ "upper-threshold"?: NativeScrollViewProps["upper-threshold"];
94
+ "lower-threshold"?: NativeScrollViewProps["lower-threshold"];
95
+ "initial-scroll-offset"?: NativeScrollViewProps["initial-scroll-offset"];
96
+ "initial-scroll-to-index"?: NativeScrollViewProps["initial-scroll-to-index"];
97
+ bindlayoutchange?: NativeScrollViewProps["bindlayoutchange"];
98
+ bindscrolltoupper?: NativeScrollViewProps["bindscrolltoupper"];
99
+ bindscrolltolower?: NativeScrollViewProps["bindscrolltolower"];
100
+ bindscroll?: NativeScrollViewProps["bindscroll"];
101
+ bindscrollend?: NativeScrollViewProps["bindscrollend"];
102
+ bindcontentsizechanged?: NativeScrollViewProps["bindcontentsizechanged"];
103
+ /** 키보드와 활성 입력 요소 사이에 확보할 간격(px). @defaultValue 24 */
104
+ keyboardGap?: number;
105
+ /** 회피 위치로 이동할 때 사용할 스크롤 방식. @defaultValue "smooth" */
106
+ scrollBehavior?: KeyboardAvoidingScrollBehavior;
107
+ }
108
+
109
+ export const KeyboardAvoidingScrollView: LynxForwardRefComponent<
110
+ NodesRef,
111
+ KeyboardAvoidingScrollViewProps
112
+ > = forwardRef<NodesRef, KeyboardAvoidingScrollViewProps>((props, forwardedRef) => {
113
+ const {
114
+ children,
115
+ keyboardGap = 24,
116
+ scrollBehavior = "smooth",
117
+ bindlayoutchange: userBindLayoutChange,
118
+ bindscroll: userBindScroll,
119
+ bindscrollend: userBindScrollEnd,
120
+ bindtouchstart: userBindTouchStart,
121
+ bindtouchend: userBindTouchEnd,
122
+ bindtouchcancel: userBindTouchCancel,
123
+ ...nativeProps
124
+ } = props;
125
+ const scrollRef = useRef<NodesRef>(null);
126
+ const spacerRef = useRef<NodesRef>(null);
127
+ const keyboardGapRef = useRef(keyboardGap);
128
+ const committedKeyboardGapRef = useRef(keyboardGap);
129
+ const scrollBehaviorRef = useRef(scrollBehavior);
130
+ const touchActiveRef = useRef(false);
131
+ const didScrollDuringTouchRef = useRef(false);
132
+
133
+ keyboardGapRef.current = keyboardGap;
134
+ scrollBehaviorRef.current = scrollBehavior;
135
+
136
+ const engine = useMemo(
137
+ () =>
138
+ createKeyboardAvoidingEngine({
139
+ driver: lynxKeyboardAvoidingNativeDriver,
140
+ scheduler: lynxKeyboardAvoidingScheduler,
141
+ getScrollNode: () => scrollRef.current,
142
+ getSpacerNode: () => spacerRef.current,
143
+ getKeyboardGap: () => keyboardGapRef.current,
144
+ getToolbarHeight: () => 0,
145
+ getSmooth: () => scrollBehaviorRef.current === "smooth",
146
+ }),
147
+ [],
148
+ );
149
+
150
+ const mergedRef = useCallback(
151
+ (node: NodesRef | null) => {
152
+ "background only";
153
+
154
+ scrollRef.current = node;
155
+ if (typeof forwardedRef === "function") {
156
+ forwardedRef(node);
157
+ } else if (forwardedRef) {
158
+ forwardedRef.current = node;
159
+ }
160
+ },
161
+ [forwardedRef],
162
+ );
163
+
164
+ const actions = useMemo<KeyboardAvoidanceActions>(
165
+ () => ({
166
+ focus(registration) {
167
+ "background only";
168
+ engine.focus(registration);
169
+ },
170
+ blur(owner) {
171
+ "background only";
172
+ engine.blur(owner);
173
+ },
174
+ layoutChanged(owner) {
175
+ "background only";
176
+ engine.layoutChanged(owner);
177
+ },
178
+ unregister(owner) {
179
+ "background only";
180
+ engine.unregister(owner);
181
+ },
182
+ }),
183
+ [engine],
184
+ );
185
+
186
+ useEffect(() => {
187
+ const unsubscribe = lynxKeyboardEventSource.subscribe((state) => {
188
+ "background only";
189
+ engine.keyboardChanged(state);
190
+ });
191
+
192
+ return () => {
193
+ unsubscribe();
194
+ engine.dispose();
195
+ };
196
+ }, [engine]);
197
+
198
+ useEffect(() => {
199
+ if (Object.is(committedKeyboardGapRef.current, keyboardGap)) return;
200
+
201
+ committedKeyboardGapRef.current = keyboardGap;
202
+ engine.viewportChanged();
203
+ }, [engine, keyboardGap]);
204
+
205
+ const handleLayoutChange = useCallback<LayoutChangeHandler>(
206
+ (...args) => {
207
+ "background only";
208
+
209
+ engine.viewportChanged();
210
+ userBindLayoutChange?.(...args);
211
+ },
212
+ [engine, userBindLayoutChange],
213
+ );
214
+
215
+ const handleTouchStart = useCallback<TouchStartHandler>(
216
+ (...args) => {
217
+ "background only";
218
+
219
+ touchActiveRef.current = true;
220
+ didScrollDuringTouchRef.current = false;
221
+ engine.userScrollStarted();
222
+ userBindTouchStart?.(...args);
223
+ },
224
+ [engine, userBindTouchStart],
225
+ );
226
+
227
+ const handleScroll = useCallback<ScrollHandler>(
228
+ (...args) => {
229
+ "background only";
230
+
231
+ if (touchActiveRef.current) {
232
+ didScrollDuringTouchRef.current = true;
233
+ }
234
+ userBindScroll?.(...args);
235
+ },
236
+ [userBindScroll],
237
+ );
238
+
239
+ const handleTouchEnd = useCallback<TouchEndHandler>(
240
+ (...args) => {
241
+ "background only";
242
+
243
+ touchActiveRef.current = false;
244
+ if (!didScrollDuringTouchRef.current) {
245
+ engine.userScrollEnded();
246
+ }
247
+ userBindTouchEnd?.(...args);
248
+ },
249
+ [engine, userBindTouchEnd],
250
+ );
251
+
252
+ const handleTouchCancel = useCallback<TouchCancelHandler>(
253
+ (...args) => {
254
+ "background only";
255
+
256
+ touchActiveRef.current = false;
257
+ didScrollDuringTouchRef.current = false;
258
+ engine.userScrollEnded();
259
+ userBindTouchCancel?.(...args);
260
+ },
261
+ [engine, userBindTouchCancel],
262
+ );
263
+
264
+ const handleScrollEnd = useCallback<ScrollEndHandler>(
265
+ (...args) => {
266
+ "background only";
267
+
268
+ touchActiveRef.current = false;
269
+ didScrollDuringTouchRef.current = false;
270
+ engine.userScrollEnded();
271
+ userBindScrollEnd?.(...args);
272
+ },
273
+ [engine, userBindScrollEnd],
274
+ );
275
+
276
+ return (
277
+ <KeyboardAvoidanceActionsContext.Provider value={actions}>
278
+ <scroll-view
279
+ {...nativeProps}
280
+ ref={mergedRef}
281
+ scroll-orientation="vertical"
282
+ flatten={false}
283
+ bindlayoutchange={handleLayoutChange}
284
+ bindtouchstart={handleTouchStart}
285
+ bindtouchend={handleTouchEnd}
286
+ bindtouchcancel={handleTouchCancel}
287
+ bindscroll={handleScroll}
288
+ bindscrollend={handleScrollEnd}
289
+ >
290
+ {children}
291
+ <view
292
+ {...({ ref: spacerRef as LynxViewRef } as Record<string, unknown>)}
293
+ {...spacerProps}
294
+ />
295
+ </scroll-view>
296
+ </KeyboardAvoidanceActionsContext.Provider>
297
+ );
298
+ });
299
+ KeyboardAvoidingScrollView.displayName = "KeyboardAvoidingScrollView";
@@ -0,0 +1,24 @@
1
+ import { createContext, useContext, type Context } from "@lynx-js/react";
2
+ import type { NodesRef } from "@lynx-js/types";
3
+
4
+ import type { KeyboardAvoidanceRegistration as EngineRegistration } from "./engine";
5
+
6
+ export type KeyboardAvoidanceRegistration = EngineRegistration<NodesRef>;
7
+
8
+ export interface KeyboardAvoidanceActions {
9
+ focus(registration: KeyboardAvoidanceRegistration): void;
10
+ blur(owner: object): void;
11
+ layoutChanged(owner: object): void;
12
+ unregister(owner: object): void;
13
+ }
14
+
15
+ export const KeyboardAvoidanceActionsContext: Context<KeyboardAvoidanceActions | null> =
16
+ createContext<KeyboardAvoidanceActions | null>(null);
17
+
18
+ /**
19
+ * Lynx TextField가 선택적으로 소비하는 내부 연결점이다.
20
+ * Provider 밖에서는 `null`을 반환해 TextField 자체 동작을 방해하지 않는다.
21
+ */
22
+ export function useKeyboardAvoidanceActions(): KeyboardAvoidanceActions | null {
23
+ return useContext(KeyboardAvoidanceActionsContext);
24
+ }