@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.
- package/lib/components/Field/Field.d.ts +47 -0
- package/lib/components/Field/Field.d.ts.map +1 -0
- package/lib/components/Field/Field.jsx +121 -0
- package/lib/components/Field/Field.namespace.d.ts +2 -0
- package/lib/components/Field/Field.namespace.d.ts.map +1 -0
- package/lib/components/Field/Field.namespace.js +16 -0
- package/lib/components/Field/context.d.ts +19 -0
- package/lib/components/Field/context.d.ts.map +1 -0
- package/lib/components/Field/context.js +10 -0
- package/lib/components/Field/index.d.ts +3 -0
- package/lib/components/Field/index.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.d.ts +42 -0
- package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.jsx +140 -0
- package/lib/components/KeyboardAvoidingScrollView/context.d.ts +17 -0
- package/lib/components/KeyboardAvoidingScrollView/context.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/context.js +12 -0
- package/lib/components/KeyboardAvoidingScrollView/engine.d.ts +50 -0
- package/lib/components/KeyboardAvoidingScrollView/engine.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/engine.js +259 -0
- package/lib/components/KeyboardAvoidingScrollView/geometry.d.ts +50 -0
- package/lib/components/KeyboardAvoidingScrollView/geometry.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/geometry.js +90 -0
- package/lib/components/KeyboardAvoidingScrollView/index.d.ts +2 -0
- package/lib/components/KeyboardAvoidingScrollView/index.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.d.ts +11 -0
- package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.js +43 -0
- package/lib/components/KeyboardAvoidingScrollView/native-driver.d.ts +34 -0
- package/lib/components/KeyboardAvoidingScrollView/native-driver.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/native-driver.js +133 -0
- package/lib/components/TextField/TextField.d.ts +113 -0
- package/lib/components/TextField/TextField.d.ts.map +1 -0
- package/lib/components/TextField/TextField.jsx +418 -0
- package/lib/components/TextField/TextField.namespace.d.ts +2 -0
- package/lib/components/TextField/TextField.namespace.d.ts.map +1 -0
- package/lib/components/TextField/TextField.namespace.js +14 -0
- package/lib/components/TextField/context.d.ts +22 -0
- package/lib/components/TextField/context.d.ts.map +1 -0
- package/lib/components/TextField/context.js +7 -0
- package/lib/components/TextField/index.d.ts +4 -0
- package/lib/components/TextField/index.d.ts.map +1 -0
- package/lib/components/TextField/useTextFieldWithGraphemes.d.ts +24 -0
- package/lib/components/TextField/useTextFieldWithGraphemes.d.ts.map +1 -0
- package/lib/components/TextField/useTextFieldWithGraphemes.js +47 -0
- package/lib/components/index.d.ts +3 -0
- package/lib/components/index.d.ts.map +1 -1
- package/lib/index.js +7 -1
- package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_grapheme_data.js +8 -0
- package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_incb_data.js +14 -0
- package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/core.js +68 -0
- package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/grapheme.js +175 -0
- package/package.json +6 -4
- package/src/components/Field/Field.namespace.ts +19 -0
- package/src/components/Field/Field.test.tsx +81 -0
- package/src/components/Field/Field.tsx +252 -0
- package/src/components/Field/context.ts +26 -0
- package/src/components/Field/index.ts +21 -0
- package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.test.tsx +267 -0
- package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.tsx +299 -0
- package/src/components/KeyboardAvoidingScrollView/context.ts +24 -0
- package/src/components/KeyboardAvoidingScrollView/engine.test.ts +451 -0
- package/src/components/KeyboardAvoidingScrollView/engine.ts +417 -0
- package/src/components/KeyboardAvoidingScrollView/geometry.test.ts +315 -0
- package/src/components/KeyboardAvoidingScrollView/geometry.ts +194 -0
- package/src/components/KeyboardAvoidingScrollView/index.ts +5 -0
- package/src/components/KeyboardAvoidingScrollView/keyboard-event-source.test.ts +122 -0
- package/src/components/KeyboardAvoidingScrollView/keyboard-event-source.ts +75 -0
- package/src/components/KeyboardAvoidingScrollView/native-driver.test.ts +223 -0
- package/src/components/KeyboardAvoidingScrollView/native-driver.ts +248 -0
- package/src/components/TextField/TextField.namespace.ts +16 -0
- package/src/components/TextField/TextField.test.tsx +924 -0
- package/src/components/TextField/TextField.tsx +912 -0
- package/src/components/TextField/context.ts +33 -0
- package/src/components/TextField/index.ts +23 -0
- package/src/components/TextField/useTextFieldWithGraphemes.test.tsx +80 -0
- package/src/components/TextField/useTextFieldWithGraphemes.ts +78 -0
- 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
|
+
});
|