@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,252 @@
1
+ import * as React from "@lynx-js/react";
2
+ import type { NodesRef } from "@lynx-js/types";
3
+ import { field, type FieldVariantProps } from "@seed-design/lynx-css/recipes/field";
4
+ import { fieldLabel, type FieldLabelVariantProps } from "@seed-design/lynx-css/recipes/field-label";
5
+ import clsx from "clsx";
6
+
7
+ import type { LynxStyledElementProps, LynxTextRef, LynxViewRef } from "../../types";
8
+ import { createSlotRecipeContext } from "../../utils/create-slot-recipe-context";
9
+ import { FieldContext, useFieldContext } from "./context";
10
+
11
+ const { ClassNamesProvider: FieldClassNamesProvider, useClassNames: useFieldClassNames } =
12
+ createSlotRecipeContext(field);
13
+ const { ClassNamesProvider: FieldLabelClassNamesProvider, useClassNames: useFieldLabelClassNames } =
14
+ createSlotRecipeContext(fieldLabel);
15
+
16
+ ////////////////////////////////////////////////////////////////////////////////////
17
+
18
+ /**
19
+ * @platform Lynx
20
+ *
21
+ * 웹 대비 미지원 기능:
22
+ * - HTML label의 `for` 연결과 native form의 `name` 제출 모델
23
+ * - DOM ARIA id 연결. 입력 컴포넌트에서 `accessibility-label`을 사용해야 함
24
+ */
25
+ export interface FieldRootProps extends Omit<FieldVariantProps, "empty">, LynxStyledElementProps {
26
+ required?: boolean;
27
+ disabled?: boolean;
28
+ readOnly?: boolean;
29
+ }
30
+
31
+ export const FieldRoot = React.forwardRef<NodesRef, FieldRootProps>((props, forwardedRef) => {
32
+ const [variantProps, otherProps] = field.splitVariantProps(props);
33
+ const {
34
+ children,
35
+ className,
36
+ required = false,
37
+ disabled = false,
38
+ readOnly = false,
39
+ ...nativeProps
40
+ } = otherProps;
41
+ const invalid = variantProps.invalid ?? false;
42
+ const [focused, setFocused] = React.useState(false);
43
+ const rootRef = React.useRef<NodesRef | null>(null);
44
+ const classes = field({ invalid });
45
+
46
+ const mergedRef = React.useCallback(
47
+ (node: NodesRef | null) => {
48
+ "background only";
49
+
50
+ rootRef.current = node;
51
+ if (typeof forwardedRef === "function") {
52
+ forwardedRef(node);
53
+ } else if (forwardedRef) {
54
+ forwardedRef.current = node;
55
+ }
56
+ },
57
+ [forwardedRef],
58
+ );
59
+
60
+ const contextValue = React.useMemo(
61
+ () => ({
62
+ rootRef,
63
+ disabled,
64
+ invalid,
65
+ readOnly,
66
+ required,
67
+ focused,
68
+ setFocused,
69
+ }),
70
+ [disabled, focused, invalid, readOnly, required],
71
+ );
72
+
73
+ return (
74
+ <FieldContext.Provider value={contextValue}>
75
+ <FieldClassNamesProvider value={classes}>
76
+ <view ref={mergedRef} className={clsx(classes.root, className)} {...nativeProps}>
77
+ {children}
78
+ </view>
79
+ </FieldClassNamesProvider>
80
+ </FieldContext.Provider>
81
+ );
82
+ });
83
+ FieldRoot.displayName = "FieldRoot";
84
+
85
+ ////////////////////////////////////////////////////////////////////////////////////
86
+
87
+ export interface FieldHeaderProps extends LynxStyledElementProps {}
88
+
89
+ export const FieldHeader = React.forwardRef<unknown, FieldHeaderProps>((props, ref) => {
90
+ const classes = useFieldClassNames();
91
+ const { children, className, ...nativeProps } = props;
92
+
93
+ return (
94
+ <view
95
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
96
+ className={clsx(classes.header, className)}
97
+ {...nativeProps}
98
+ >
99
+ {children}
100
+ </view>
101
+ );
102
+ });
103
+ FieldHeader.displayName = "FieldHeader";
104
+
105
+ export interface FieldLabelProps extends FieldLabelVariantProps, LynxStyledElementProps {}
106
+
107
+ export const FieldLabel = React.forwardRef<unknown, FieldLabelProps>((props, ref) => {
108
+ const [variantProps, otherProps] = fieldLabel.splitVariantProps(props);
109
+ const { children, className, ...nativeProps } = otherProps;
110
+ const classes = fieldLabel(variantProps);
111
+
112
+ return (
113
+ <FieldLabelClassNamesProvider value={classes}>
114
+ <text
115
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
116
+ className={clsx(classes.root, className)}
117
+ {...nativeProps}
118
+ >
119
+ {children}
120
+ </text>
121
+ </FieldLabelClassNamesProvider>
122
+ );
123
+ });
124
+ FieldLabel.displayName = "FieldLabel";
125
+
126
+ export interface FieldIndicatorTextProps extends LynxStyledElementProps {}
127
+
128
+ export const FieldIndicatorText = React.forwardRef<unknown, FieldIndicatorTextProps>(
129
+ (props, ref) => {
130
+ const classes = useFieldLabelClassNames();
131
+ const { children, className, ...nativeProps } = props;
132
+
133
+ return (
134
+ <text
135
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
136
+ className={clsx(classes.indicatorText, className)}
137
+ {...nativeProps}
138
+ >
139
+ {"\u00a0"}
140
+ {children}
141
+ </text>
142
+ );
143
+ },
144
+ );
145
+ FieldIndicatorText.displayName = "FieldIndicatorText";
146
+
147
+ export interface FieldRequiredIndicatorProps extends LynxStyledElementProps {}
148
+
149
+ export const FieldRequiredIndicator = React.forwardRef<unknown, FieldRequiredIndicatorProps>(
150
+ (props, ref) => {
151
+ const classes = useFieldLabelClassNames();
152
+ const { children = "*", className, ...nativeProps } = props;
153
+
154
+ return (
155
+ <text
156
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
157
+ accessibility-elements-hidden={true}
158
+ className={clsx(classes.indicatorIcon, className)}
159
+ {...nativeProps}
160
+ >
161
+ {"\u200a"}
162
+ {children}
163
+ </text>
164
+ );
165
+ },
166
+ );
167
+ FieldRequiredIndicator.displayName = "FieldRequiredIndicator";
168
+
169
+ ////////////////////////////////////////////////////////////////////////////////////
170
+
171
+ export interface FieldFooterProps extends LynxStyledElementProps {}
172
+
173
+ export const FieldFooter = React.forwardRef<unknown, FieldFooterProps>((props, ref) => {
174
+ const classes = useFieldClassNames();
175
+ const { children, className, ...nativeProps } = props;
176
+
177
+ return (
178
+ <view
179
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
180
+ className={clsx(classes.footer, className)}
181
+ {...nativeProps}
182
+ >
183
+ {children}
184
+ </view>
185
+ );
186
+ });
187
+ FieldFooter.displayName = "FieldFooter";
188
+
189
+ export interface FieldDescriptionProps extends LynxStyledElementProps {}
190
+
191
+ export const FieldDescription = React.forwardRef<unknown, FieldDescriptionProps>((props, ref) => {
192
+ const classes = useFieldClassNames();
193
+ const { children, className, ...nativeProps } = props;
194
+
195
+ return (
196
+ <text
197
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
198
+ className={clsx(classes.description, className)}
199
+ {...nativeProps}
200
+ >
201
+ {children}
202
+ </text>
203
+ );
204
+ });
205
+ FieldDescription.displayName = "FieldDescription";
206
+
207
+ export interface FieldErrorMessageProps extends LynxStyledElementProps {}
208
+
209
+ export const FieldErrorMessage = React.forwardRef<unknown, FieldErrorMessageProps>((props, ref) => {
210
+ const classes = useFieldClassNames();
211
+ const { children, className, ...nativeProps } = props;
212
+
213
+ return (
214
+ <text
215
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
216
+ className={clsx(classes.errorMessage, className)}
217
+ {...nativeProps}
218
+ >
219
+ {children}
220
+ </text>
221
+ );
222
+ });
223
+ FieldErrorMessage.displayName = "FieldErrorMessage";
224
+
225
+ ////////////////////////////////////////////////////////////////////////////////////
226
+
227
+ export interface FieldCharacterCountProps extends LynxStyledElementProps {
228
+ /** 현재 문자 또는 grapheme 수 */
229
+ current: number;
230
+ /** 허용하는 최대 문자 또는 grapheme 수 */
231
+ max: number;
232
+ }
233
+
234
+ export const FieldCharacterCount = React.forwardRef<unknown, FieldCharacterCountProps>(
235
+ (props, ref) => {
236
+ const { current, max, children: _children, className, ...nativeProps } = props;
237
+ const context = useFieldContext({ strict: true });
238
+ const classes = field({ invalid: context.invalid, empty: current === 0 });
239
+
240
+ return (
241
+ <view
242
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
243
+ className={clsx(classes.characterCountArea, className)}
244
+ {...nativeProps}
245
+ >
246
+ <text className={classes.characterCount}>{current}</text>
247
+ <text className={classes.maxCharacterCount}>/{max}</text>
248
+ </view>
249
+ );
250
+ },
251
+ );
252
+ FieldCharacterCount.displayName = "FieldCharacterCount";
@@ -0,0 +1,26 @@
1
+ import * as React from "@lynx-js/react";
2
+ import type { NodesRef } from "@lynx-js/types";
3
+
4
+ export interface FieldContextValue {
5
+ rootRef: React.RefObject<NodesRef | null>;
6
+ disabled: boolean;
7
+ invalid: boolean;
8
+ readOnly: boolean;
9
+ required: boolean;
10
+ focused: boolean;
11
+ setFocused: (focused: boolean) => void;
12
+ }
13
+
14
+ export const FieldContext = React.createContext<FieldContextValue | null>(null);
15
+
16
+ export function useFieldContext(options: { strict: true }): FieldContextValue;
17
+ export function useFieldContext(options?: { strict?: false }): FieldContextValue | null;
18
+ export function useFieldContext(options: { strict?: boolean } = {}): FieldContextValue | null {
19
+ const context = React.useContext(FieldContext);
20
+
21
+ if (options.strict && !context) {
22
+ throw new Error("Field compound components must be rendered inside <Field.Root>.");
23
+ }
24
+
25
+ return context;
26
+ }
@@ -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
+ });