@seed-design/lynx-react 0.4.0 → 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.
- package/lib/components/ProgressCircle/ProgressCircle.d.ts.map +1 -1
- package/lib/components/ProgressCircle/ProgressCircle.jsx +36 -5
- package/lib/components/Stack/Stack.d.ts.map +1 -1
- package/lib/components/Stack/Stack.jsx +10 -1
- package/lib/components/TagGroup/TagGroup.d.ts +2 -1
- package/lib/components/TagGroup/TagGroup.d.ts.map +1 -1
- package/lib/components/TagGroup/TagGroup.jsx +2 -1
- package/lib/components/TextField/TextField.d.ts +20 -2
- package/lib/components/TextField/TextField.d.ts.map +1 -1
- package/lib/components/TextField/TextField.jsx +23 -7
- package/package.json +3 -3
- package/src/components/ProgressCircle/ProgressCircle.test.tsx +58 -0
- package/src/components/ProgressCircle/ProgressCircle.tsx +42 -5
- package/src/components/Stack/Stack.test.tsx +67 -0
- package/src/components/Stack/Stack.tsx +15 -1
- package/src/components/TagGroup/TagGroup.test.tsx +73 -0
- package/src/components/TagGroup/TagGroup.tsx +4 -2
- package/src/components/TextField/TextField.test.tsx +163 -35
- package/src/components/TextField/TextField.tsx +61 -19
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProgressCircle.d.ts","sourceRoot":"","sources":["../../../src/components/ProgressCircle/ProgressCircle.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,KAAK,MAAM,gBAAgB,CAAC;AAa7C,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,+CAA+C,CAAC;AAChG,OAAO,KAAK,EAAE,sBAAsB,EAAe,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"ProgressCircle.d.ts","sourceRoot":"","sources":["../../../src/components/ProgressCircle/ProgressCircle.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,KAAK,MAAM,gBAAgB,CAAC;AAa7C,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,+CAA+C,CAAC;AAChG,OAAO,KAAK,EAAE,sBAAsB,EAAe,MAAM,aAAa,CAAC;AA2IvE,MAAM,WAAW,uBACf,SAAQ,0BAA0B,EAChC,sBAAsB;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,SAAS,GAAG,uBAAuB,CAAC;AAIhD;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,yFA8B7B,CAAC;AAIH,eAAO,MAAM,mBAAmB;;;CAQ/B,CAAC"}
|
|
@@ -18,6 +18,34 @@ function computeRingGeometry(numSize) {
|
|
|
18
18
|
capSize: halfSize - innerR
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
function bgCubicBezier(rawT, x1, y1, x2, y2) {
|
|
22
|
+
const t = Math.max(0, Math.min(1, rawT));
|
|
23
|
+
const cx = 3 * x1;
|
|
24
|
+
const bx = 3 * (x2 - x1) - cx;
|
|
25
|
+
const ax = 1 - cx - bx;
|
|
26
|
+
let currentT = t;
|
|
27
|
+
for (let i = 0; i < 8; i++) {
|
|
28
|
+
const currentX = ((ax * currentT + bx) * currentT + cx) * currentT - t;
|
|
29
|
+
const currentDx = (3 * ax * currentT + 2 * bx) * currentT + cx;
|
|
30
|
+
if (Math.abs(currentDx) < 1e-6) break;
|
|
31
|
+
currentT = currentT - currentX / currentDx;
|
|
32
|
+
}
|
|
33
|
+
const cy = 3 * y1;
|
|
34
|
+
const by = 3 * (y2 - y1) - cy;
|
|
35
|
+
return (((1 - cy - by) * currentT + by) * currentT + cy) * currentT;
|
|
36
|
+
}
|
|
37
|
+
function bgSampleIndeterminate(t) {
|
|
38
|
+
const containerEased = bgCubicBezier(t, .35, .25, .65, .75);
|
|
39
|
+
const headEased = bgCubicBezier(t, .35, 0, .65, 1);
|
|
40
|
+
const tailEased = bgCubicBezier(t, .35, 0, .65, .6);
|
|
41
|
+
const headLength = headEased <= .75 ? headEased / .75 * 360 : 360;
|
|
42
|
+
const tailOffset = tailEased <= 1 / 3 ? 0 : (tailEased - 1 / 3) / (2 / 3) * 360;
|
|
43
|
+
const arcLength = Math.max(0, headLength - tailOffset);
|
|
44
|
+
return {
|
|
45
|
+
containerDeg: containerEased * 360 + tailOffset,
|
|
46
|
+
arcLength
|
|
47
|
+
};
|
|
48
|
+
}
|
|
21
49
|
function bgPieClipPath(size, angleDeg) {
|
|
22
50
|
if (angleDeg >= 360) return void 0;
|
|
23
51
|
if (angleDeg <= 0) return "path(\"M 0 0 Z\")";
|
|
@@ -72,6 +100,7 @@ function sampleIndeterminate(t) {
|
|
|
72
100
|
};
|
|
73
101
|
}
|
|
74
102
|
var INDETERMINATE_DURATION = 1200;
|
|
103
|
+
var INDETERMINATE_INITIAL_PHASE = 1 / 6;
|
|
75
104
|
var TRANSITION_DURATION = 300;
|
|
76
105
|
/**
|
|
77
106
|
* Lynx에서 SVG를 사용할 수 없어 CSS clip-path 기반 pie sector로 구현.
|
|
@@ -220,7 +249,7 @@ function IndeterminateRange({ numSize, classes }) {
|
|
|
220
249
|
let startTs = 0;
|
|
221
250
|
function tick(ts) {
|
|
222
251
|
if (!startTs) startTs = Number(ts);
|
|
223
|
-
const state = sampleIndeterminate((ts - startTs) % INDETERMINATE_DURATION / INDETERMINATE_DURATION);
|
|
252
|
+
const state = sampleIndeterminate((ts - startTs + INDETERMINATE_DURATION * INDETERMINATE_INITIAL_PHASE) % INDETERMINATE_DURATION / INDETERMINATE_DURATION);
|
|
224
253
|
containerEl?.setStyleProperty("transform", `rotate(${state.containerDeg}deg)`);
|
|
225
254
|
const clipPath = pieClipPath(numSize, state.arcLength);
|
|
226
255
|
if (clipPath) rangeEl?.setStyleProperty("clip-path", clipPath);
|
|
@@ -247,12 +276,14 @@ function IndeterminateRange({ numSize, classes }) {
|
|
|
247
276
|
runOnMainThread(stopLoop)();
|
|
248
277
|
};
|
|
249
278
|
}, [numSize]);
|
|
250
|
-
const
|
|
279
|
+
const initialState = bgSampleIndeterminate(INDETERMINATE_INITIAL_PHASE);
|
|
280
|
+
const initialClipPath = bgPieClipPath(numSize, initialState.arcLength);
|
|
281
|
+
const initialHeadRad = initialState.arcLength * Math.PI / 180;
|
|
251
282
|
return <view main-thread:ref={containerRef} style={{
|
|
252
283
|
position: "absolute",
|
|
253
284
|
width: `${numSize}px`,
|
|
254
285
|
height: `${numSize}px`,
|
|
255
|
-
transform:
|
|
286
|
+
transform: `rotate(${initialState.containerDeg}deg)`
|
|
256
287
|
}}>
|
|
257
288
|
<view main-thread:ref={rangeRef} className={classes.range} style={{
|
|
258
289
|
position: "absolute",
|
|
@@ -270,8 +301,8 @@ function IndeterminateRange({ numSize, classes }) {
|
|
|
270
301
|
<view main-thread:ref={headCapRef} className={classes.cap} style={{
|
|
271
302
|
width: `${capSize}px`,
|
|
272
303
|
height: `${capSize}px`,
|
|
273
|
-
left: `${halfSize - capSize / 2}px`,
|
|
274
|
-
top: `${halfSize - ringCenterR - capSize / 2}px`
|
|
304
|
+
left: `${halfSize + ringCenterR * Math.sin(initialHeadRad) - capSize / 2}px`,
|
|
305
|
+
top: `${halfSize - ringCenterR * Math.cos(initialHeadRad) - capSize / 2}px`
|
|
275
306
|
}} />
|
|
276
307
|
</view>;
|
|
277
308
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Stack.d.ts","sourceRoot":"","sources":["../../../src/components/Stack/Stack.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AAExC,OAAO,KAAK,EAAE,kBAAkB,EAAE,sBAAsB,EAAe,MAAM,aAAa,CAAC;AAC3F,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAEpE,KAAK,eAAe,GAChB,SAAS,GACT,eAAe,GACf,YAAY,GACZ,gBAAgB,GAChB,UAAU,GACV,UAAU,GACV,YAAY,CAAC;AAEjB,UAAU,cAAe,SAAQ,UAAU,EAAE,sBAAsB,EAAE,kBAAkB;IACrF,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,IAAI,CAAC;CAC9B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,UAAW,SAAQ,IAAI,CAAC,cAAc,EAAE,eAAe,CAAC;IACvE,KAAK,CAAC,EAAE,cAAc,CAAC,YAAY,CAAC,CAAC;IACrC,OAAO,CAAC,EAAE,cAAc,CAAC,gBAAgB,CAAC,CAAC;IAC3C,IAAI,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IAClC,IAAI,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IAClC,MAAM,CAAC,EAAE,cAAc,CAAC,YAAY,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,WAAY,SAAQ,UAAU;CAAG;AAElD,MAAM,WAAW,WAAY,SAAQ,UAAU;CAAG;
|
|
1
|
+
{"version":3,"file":"Stack.d.ts","sourceRoot":"","sources":["../../../src/components/Stack/Stack.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AAExC,OAAO,KAAK,EAAE,kBAAkB,EAAE,sBAAsB,EAAe,MAAM,aAAa,CAAC;AAC3F,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAEpE,KAAK,eAAe,GAChB,SAAS,GACT,eAAe,GACf,YAAY,GACZ,gBAAgB,GAChB,UAAU,GACV,UAAU,GACV,YAAY,CAAC;AAEjB,UAAU,cAAe,SAAQ,UAAU,EAAE,sBAAsB,EAAE,kBAAkB;IACrF,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,IAAI,CAAC;CAC9B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,UAAW,SAAQ,IAAI,CAAC,cAAc,EAAE,eAAe,CAAC;IACvE,KAAK,CAAC,EAAE,cAAc,CAAC,YAAY,CAAC,CAAC;IACrC,OAAO,CAAC,EAAE,cAAc,CAAC,gBAAgB,CAAC,CAAC;IAC3C,IAAI,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IAClC,IAAI,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IAClC,MAAM,CAAC,EAAE,cAAc,CAAC,YAAY,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,WAAY,SAAQ,UAAU;CAAG;AAElD,MAAM,WAAW,WAAY,SAAQ,UAAU;CAAG;AAwDlD,eAAO,MAAM,MAAM,6EAIjB,CAAC;AAIH,eAAO,MAAM,MAAM,6EAIjB,CAAC"}
|
|
@@ -14,11 +14,20 @@ function getStackProps(props) {
|
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
16
|
function useStackStyleProps(props, flexDirection) {
|
|
17
|
-
|
|
17
|
+
const stackStyleProps = useStyleProps({
|
|
18
18
|
display: "flex",
|
|
19
19
|
flexDirection,
|
|
20
20
|
...getStackProps(props)
|
|
21
21
|
});
|
|
22
|
+
const { gap, ...style } = stackStyleProps.style;
|
|
23
|
+
if (gap == null) return stackStyleProps;
|
|
24
|
+
return {
|
|
25
|
+
...stackStyleProps,
|
|
26
|
+
style: {
|
|
27
|
+
[flexDirection === "column" ? "rowGap" : "columnGap"]: gap,
|
|
28
|
+
...style
|
|
29
|
+
}
|
|
30
|
+
};
|
|
22
31
|
}
|
|
23
32
|
function renderStackView(stackStyleProps, ref) {
|
|
24
33
|
const { style, restProps } = stackStyleProps;
|
|
@@ -14,7 +14,8 @@ import * as React from "@lynx-js/react";
|
|
|
14
14
|
*/
|
|
15
15
|
export interface TagGroupRootProps extends TagGroupVariantProps, TagGroupItemVariantProps, LynxStyledElementProps {
|
|
16
16
|
/**
|
|
17
|
-
* children 사이에 삽입되는 구분자. 기본값 `"
|
|
17
|
+
* children 사이에 삽입되는 구분자. 기본값 `"·"`.
|
|
18
|
+
* 문자열을 전달하면 앞뒤 공백을 제거합니다.
|
|
18
19
|
*/
|
|
19
20
|
separator?: React.ReactNode;
|
|
20
21
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TagGroup.d.ts","sourceRoot":"","sources":["../../../src/components/TagGroup/TagGroup.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AAExC,OAAO,EAAY,KAAK,oBAAoB,EAAE,MAAM,yCAAyC,CAAC;AAC9F,OAAO,EAEL,KAAK,wBAAwB,EAC9B,MAAM,8CAA8C,CAAC;AAEtD,OAAO,KAAK,EAAE,sBAAsB,EAA4B,MAAM,aAAa,CAAC;AAIpF,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAOpE;;;;;;;;GAQG;AACH,MAAM,WAAW,iBACf,SAAQ,oBAAoB,EAC1B,wBAAwB,EACxB,sBAAsB;IACxB
|
|
1
|
+
{"version":3,"file":"TagGroup.d.ts","sourceRoot":"","sources":["../../../src/components/TagGroup/TagGroup.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AAExC,OAAO,EAAY,KAAK,oBAAoB,EAAE,MAAM,yCAAyC,CAAC;AAC9F,OAAO,EAEL,KAAK,wBAAwB,EAC9B,MAAM,8CAA8C,CAAC;AAEtD,OAAO,KAAK,EAAE,sBAAsB,EAA4B,MAAM,aAAa,CAAC;AAIpF,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAOpE;;;;;;;;GAQG;AACH,MAAM,WAAW,iBACf,SAAQ,oBAAoB,EAC1B,wBAAwB,EACxB,sBAAsB;IACxB;;;OAGG;IACH,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC7B;AAED,eAAO,MAAM,YAAY,mFAkCvB,CAAC;AAKH;;;;;GAKG;AACH,MAAM,WAAW,iBACf,SAAQ,wBAAwB,EAC9B,IAAI,CAAC,UAAU,EAAE,YAAY,CAAC,EAC9B,sBAAsB;CAAG;AAE7B,eAAO,MAAM,YAAY,mFAmBvB,CAAC;AAKH,MAAM,WAAW,sBAAuB,SAAQ,sBAAsB;CAAG;AAEzE,eAAO,MAAM,iBAAiB,wFAY5B,CAAC"}
|
|
@@ -15,13 +15,14 @@ var TagGroupRoot = React.forwardRef((props, ref) => {
|
|
|
15
15
|
});
|
|
16
16
|
const { children, className, separator = " · ", ...nativeProps } = otherProps;
|
|
17
17
|
const classes = tagGroup(tagGroupVariantProps);
|
|
18
|
+
const normalizedSeparator = typeof separator === "string" ? separator.trim() : separator;
|
|
18
19
|
const visibleChildren = toArray(children);
|
|
19
20
|
return <PropsProvider value={tagGroupItemVariantProps}>
|
|
20
21
|
<view {...ref ? { ref } : {}} className={clsx(classes.root, className)} {...nativeProps}>
|
|
21
22
|
{visibleChildren.map((child, index) => {
|
|
22
23
|
if (index === 0) return child;
|
|
23
24
|
return <view key={child.key ?? index} className={classes.separatorWrapper}>
|
|
24
|
-
<text className={classes.separator}>{
|
|
25
|
+
<text className={classes.separator}>{normalizedSeparator}</text>
|
|
25
26
|
{child}
|
|
26
27
|
</view>;
|
|
27
28
|
})}
|
|
@@ -44,13 +44,22 @@ export interface TextFieldInputProps extends NativeTextControlProps {
|
|
|
44
44
|
maxlength?: NativeInputProps["maxlength"];
|
|
45
45
|
readonly?: NativeInputProps["readonly"];
|
|
46
46
|
disabled?: NativeInputProps["disabled"];
|
|
47
|
+
/**
|
|
48
|
+
* 포커스할 때 시스템 키보드를 표시한다.
|
|
49
|
+
* `undefined`가 native attribute로 전달되지 않도록 `true`를 명시적으로 적용한다.
|
|
50
|
+
* @defaultValue true
|
|
51
|
+
*/
|
|
47
52
|
"show-soft-input-on-focus"?: NativeInputProps["show-soft-input-on-focus"];
|
|
48
53
|
"input-filter"?: NativeInputProps["input-filter"];
|
|
49
54
|
type?: NativeInputProps["type"];
|
|
50
55
|
"ios-auto-correct"?: NativeInputProps["ios-auto-correct"];
|
|
51
56
|
"ios-spell-check"?: NativeInputProps["ios-spell-check"];
|
|
52
57
|
"android-fullscreen-mode"?: NativeInputProps["android-fullscreen-mode"];
|
|
53
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Android host window의 soft input mode를 지정한다.
|
|
60
|
+
* `undefined`가 native attribute로 전달되지 않도록 `"unspecified"`를 명시적으로 적용한다.
|
|
61
|
+
* @defaultValue "unspecified"
|
|
62
|
+
*/
|
|
54
63
|
"android-set-soft-input-mode"?: AndroidSetSoftInputMode;
|
|
55
64
|
bindfocus?: NativeInputProps["bindfocus"];
|
|
56
65
|
bindblur?: NativeInputProps["bindblur"];
|
|
@@ -80,6 +89,11 @@ export interface TextFieldTextareaProps extends NativeTextControlProps {
|
|
|
80
89
|
"line-spacing"?: NativeTextareaProps["line-spacing"];
|
|
81
90
|
readonly?: NativeTextareaProps["readonly"];
|
|
82
91
|
disabled?: NativeTextareaProps["disabled"];
|
|
92
|
+
/**
|
|
93
|
+
* 포커스할 때 시스템 키보드를 표시한다.
|
|
94
|
+
* `undefined`가 native attribute로 전달되지 않도록 `true`를 명시적으로 적용한다.
|
|
95
|
+
* @defaultValue true
|
|
96
|
+
*/
|
|
83
97
|
"show-soft-input-on-focus"?: NativeTextareaProps["show-soft-input-on-focus"];
|
|
84
98
|
"input-filter"?: NativeTextareaProps["input-filter"];
|
|
85
99
|
"enable-scroll-bar"?: NativeTextareaProps["enable-scroll-bar"];
|
|
@@ -88,7 +102,11 @@ export interface TextFieldTextareaProps extends NativeTextControlProps {
|
|
|
88
102
|
"ios-spell-check"?: NativeTextareaProps["ios-spell-check"];
|
|
89
103
|
/** Android의 fullscreen extract input을 활성화한다. @defaultValue false */
|
|
90
104
|
"android-fullscreen-mode"?: NativeTextareaProps["android-fullscreen-mode"];
|
|
91
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Android host window의 soft input mode를 지정한다.
|
|
107
|
+
* `undefined`가 native attribute로 전달되지 않도록 `"unspecified"`를 명시적으로 적용한다.
|
|
108
|
+
* @defaultValue "unspecified"
|
|
109
|
+
*/
|
|
92
110
|
"android-set-soft-input-mode"?: AndroidSetSoftInputMode;
|
|
93
111
|
bindfocus?: NativeTextareaProps["bindfocus"];
|
|
94
112
|
bindblur?: NativeTextareaProps["bindblur"];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TextField.d.ts","sourceRoot":"","sources":["../../../src/components/TextField/TextField.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AACxC,OAAO,KAAK,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAO,EAAa,KAAK,qBAAqB,EAAE,MAAM,0CAA0C,CAAC;AAIjG,OAAO,KAAK,EAAE,sBAAsB,EAAE,sBAAsB,EAAe,MAAM,aAAa,CAAC;AAG/F,OAAO,EAAgB,KAAK,iBAAiB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"TextField.d.ts","sourceRoot":"","sources":["../../../src/components/TextField/TextField.tsx"],"names":[],"mappings":"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"}
|
|
@@ -9,8 +9,14 @@ import * as React from "@lynx-js/react";
|
|
|
9
9
|
import { textInput as textInput$1 } from "@seed-design/lynx-css/recipes/text-input";
|
|
10
10
|
//#region src/components/TextField/TextField.tsx
|
|
11
11
|
var ANDROID_TEXTAREA_DEFAULT_LINE_SPACING = "3.2px";
|
|
12
|
+
function getRuntimePlatform() {
|
|
13
|
+
return (globalThis.SystemInfo ?? (typeof SystemInfo === "undefined" ? void 0 : SystemInfo))?.platform;
|
|
14
|
+
}
|
|
12
15
|
function isAndroidRuntime() {
|
|
13
|
-
return (
|
|
16
|
+
return getRuntimePlatform() === "Android";
|
|
17
|
+
}
|
|
18
|
+
function isIOSRuntime() {
|
|
19
|
+
return getRuntimePlatform() === "iOS";
|
|
14
20
|
}
|
|
15
21
|
var { ClassNamesProvider, useClassNames } = createSlotRecipeContext(textInput$1);
|
|
16
22
|
var TextFieldRoot = React.forwardRef((props, forwardedRef) => {
|
|
@@ -340,7 +346,7 @@ var TextFieldInput = React.forwardRef((props, ref) => {
|
|
|
340
346
|
{displayValue}
|
|
341
347
|
</text>;
|
|
342
348
|
}
|
|
343
|
-
return <input {...control.defaultValueProps} ref={control.mergedRef} className={clsx(classes.value, className)} disabled={control.disabled} readonly={control.readOnly} show-soft-input-on-focus={
|
|
349
|
+
return <input {...control.defaultValueProps} ref={control.mergedRef} className={clsx(classes.value, className)} disabled={control.disabled} readonly={control.readOnly} show-soft-input-on-focus={showSoftInputOnFocus ?? true} android-set-soft-input-mode={androidSetSoftInputMode ?? "unspecified"} name={name ?? control.context.name} {...resolvedMaxLength === void 0 ? {} : { maxlength: resolvedMaxLength }} bindinput={control.handleInput} bindselection={handleSelection} bindfocus={control.handleFocus} bindblur={control.handleBlur} {...nativeProps} />;
|
|
344
350
|
});
|
|
345
351
|
TextFieldInput.displayName = "TextFieldInput";
|
|
346
352
|
var TextFieldTextarea = React.forwardRef((props, ref) => {
|
|
@@ -356,17 +362,27 @@ var TextFieldTextarea = React.forwardRef((props, ref) => {
|
|
|
356
362
|
});
|
|
357
363
|
const resolvedMaxLength = useResolvedNativeMaxLength(maxlength, control.nativeInsertionMaxLength);
|
|
358
364
|
const isAndroid = isAndroidRuntime();
|
|
365
|
+
const usesIOSAutoresizeWrapper = autoresize && isIOSRuntime();
|
|
359
366
|
const resolvedLineSpacing = lineSpacing !== void 0 ? lineSpacing : isAndroid ? ANDROID_TEXTAREA_DEFAULT_LINE_SPACING : void 0;
|
|
360
367
|
const handleSelection = React.useCallback((event) => {
|
|
361
368
|
"background only";
|
|
362
369
|
control.handleSelectionChange(event.detail);
|
|
363
370
|
bindselection?.(event);
|
|
364
371
|
}, [bindselection, control.handleSelectionChange]);
|
|
372
|
+
const handleLayoutChange = React.useCallback((event) => {
|
|
373
|
+
"background only";
|
|
374
|
+
if (autoresize) control.notifyLayoutChanged();
|
|
375
|
+
bindlayoutchange?.(event);
|
|
376
|
+
}, [
|
|
377
|
+
autoresize,
|
|
378
|
+
bindlayoutchange,
|
|
379
|
+
control.notifyLayoutChanged
|
|
380
|
+
]);
|
|
365
381
|
const handleTextareaRootTap = React.useCallback((event) => {
|
|
366
382
|
"background only";
|
|
367
|
-
if (event.target.uid !== event.currentTarget.uid) return;
|
|
383
|
+
if (control.disabled || event.target.uid !== event.currentTarget.uid) return;
|
|
368
384
|
control.focusNativeControl();
|
|
369
|
-
}, [control.focusNativeControl]);
|
|
385
|
+
}, [control.disabled, control.focusNativeControl]);
|
|
370
386
|
if (control.readOnly) {
|
|
371
387
|
const value = control.context.value;
|
|
372
388
|
const isPlaceholder = value === "";
|
|
@@ -379,9 +395,9 @@ var TextFieldTextarea = React.forwardRef((props, ref) => {
|
|
|
379
395
|
{isPlaceholder ? props.placeholder : value}
|
|
380
396
|
</text>;
|
|
381
397
|
}
|
|
382
|
-
const textarea = <textarea {...control.defaultValueProps} ref={control.mergedRef} className={clsx(classes.value, classes.textareaValue, autoresize
|
|
383
|
-
if (!
|
|
384
|
-
return <view className={clsx(classes.textareaRoot,
|
|
398
|
+
const textarea = <textarea {...control.defaultValueProps} ref={control.mergedRef} className={clsx(classes.value, classes.textareaValue, !autoresize && classes.textareaFixed, usesIOSAutoresizeWrapper && classes.textareaNativeAutoresize, autoresize && !usesIOSAutoresizeWrapper && classes.textareaAndroidAutoresize, className)} disabled={control.disabled} readonly={control.readOnly} show-soft-input-on-focus={showSoftInputOnFocus ?? true} bounces={bounces ?? (autoresize ? false : void 0)} line-spacing={resolvedLineSpacing} android-fullscreen-mode={androidFullscreenMode ?? false} android-set-soft-input-mode={androidSetSoftInputMode ?? "unspecified"} name={name ?? control.context.name} {...resolvedMaxLength === void 0 ? {} : { maxlength: resolvedMaxLength }} bindinput={control.handleInput} bindselection={handleSelection} bindfocus={control.handleFocus} bindblur={control.handleBlur} bindlayoutchange={usesIOSAutoresizeWrapper ? bindlayoutchange : handleLayoutChange} {...nativeProps} />;
|
|
399
|
+
if (!usesIOSAutoresizeWrapper) return textarea;
|
|
400
|
+
return <view ignore-focus={true} className={clsx(classes.textareaRoot, classes.textareaAutoresizeRoot)} bindtap={handleTextareaRootTap} bindlayoutchange={control.notifyLayoutChanged}>
|
|
385
401
|
{textarea}
|
|
386
402
|
</view>;
|
|
387
403
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seed-design/lynx-react",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/daangn/seed-design.git",
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"@lynx-js/react": ">=0.117.0",
|
|
36
36
|
"@lynx-js/types": ">=3.7.0",
|
|
37
|
-
"@seed-design/lynx-css": "0.0.0 || >=0.
|
|
37
|
+
"@seed-design/lynx-css": "0.0.0 || >=0.8.0 <1.0.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@lynx-js/react": "^0.117.0",
|
|
41
41
|
"@lynx-js/types": "^3.7.0",
|
|
42
|
-
"@seed-design/lynx-css": "0.8.
|
|
42
|
+
"@seed-design/lynx-css": "0.8.1",
|
|
43
43
|
"@testing-library/jest-dom": "^6.9.1",
|
|
44
44
|
"jsdom": "^29.0.2",
|
|
45
45
|
"preact": "npm:@lynx-js/internal-preact@10.28.4-ee7bb26",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import { render } from "@lynx-js/react/testing-library";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { ProgressCircle } from "./index";
|
|
6
|
+
|
|
7
|
+
vi.mock("@lynx-js/react", async (importOriginal) => {
|
|
8
|
+
const actual = await importOriginal<typeof import("@lynx-js/react")>();
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
runOnMainThread: () => () => undefined,
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function getRenderedRoot() {
|
|
17
|
+
const root = elementTree.root;
|
|
18
|
+
|
|
19
|
+
if (!root) {
|
|
20
|
+
throw new Error("Expected Lynx render root to exist.");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return root;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("ProgressCircle", () => {
|
|
27
|
+
it("renders a visible indeterminate range before the main-thread animation starts", () => {
|
|
28
|
+
render(
|
|
29
|
+
<ProgressCircle.Root>
|
|
30
|
+
<ProgressCircle.Range />
|
|
31
|
+
</ProgressCircle.Root>,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const root = getRenderedRoot();
|
|
35
|
+
const range = root.querySelector<HTMLElement>(".seed-progress-circle__range");
|
|
36
|
+
const caps = root.querySelectorAll<HTMLElement>(".seed-progress-circle__cap");
|
|
37
|
+
const animationContainer = range?.parentElement;
|
|
38
|
+
|
|
39
|
+
expect(range).toBeInTheDocument();
|
|
40
|
+
expect(animationContainer?.style.transform).not.toBe("rotate(0deg)");
|
|
41
|
+
expect(range?.style.clipPath).toMatch(/^path\("M 20 20 L 20 -1 A 21 21/);
|
|
42
|
+
expect(caps).toHaveLength(2);
|
|
43
|
+
expect(caps[1]?.style.left).not.toBe(caps[0]?.style.left);
|
|
44
|
+
expect(caps[1]?.style.top).not.toBe(caps[0]?.style.top);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps the determinate range at the requested initial value", () => {
|
|
48
|
+
render(
|
|
49
|
+
<ProgressCircle.Root minValue={0} maxValue={100} value={40}>
|
|
50
|
+
<ProgressCircle.Range />
|
|
51
|
+
</ProgressCircle.Root>,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const range = getRenderedRoot().querySelector<HTMLElement>(".seed-progress-circle__range");
|
|
55
|
+
|
|
56
|
+
expect(range?.style.clipPath).toMatch(/^path\("M 20 20 L 20 -1 A 21 21/);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -45,6 +45,37 @@ function computeRingGeometry(numSize: number) {
|
|
|
45
45
|
return { halfSize, innerR, ringCenterR, capSize };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function bgCubicBezier(rawT: number, x1: number, y1: number, x2: number, y2: number): number {
|
|
49
|
+
const t = Math.max(0, Math.min(1, rawT));
|
|
50
|
+
const cx = 3 * x1;
|
|
51
|
+
const bx = 3 * (x2 - x1) - cx;
|
|
52
|
+
const ax = 1 - cx - bx;
|
|
53
|
+
let currentT = t;
|
|
54
|
+
for (let i = 0; i < 8; i++) {
|
|
55
|
+
const currentX = ((ax * currentT + bx) * currentT + cx) * currentT - t;
|
|
56
|
+
const currentDx = (3 * ax * currentT + 2 * bx) * currentT + cx;
|
|
57
|
+
if (Math.abs(currentDx) < 1e-6) break;
|
|
58
|
+
currentT = currentT - currentX / currentDx;
|
|
59
|
+
}
|
|
60
|
+
const cy = 3 * y1;
|
|
61
|
+
const by = 3 * (y2 - y1) - cy;
|
|
62
|
+
const ay = 1 - cy - by;
|
|
63
|
+
return ((ay * currentT + by) * currentT + cy) * currentT;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function bgSampleIndeterminate(t: number) {
|
|
67
|
+
const containerEased = bgCubicBezier(t, 0.35, 0.25, 0.65, 0.75);
|
|
68
|
+
const headEased = bgCubicBezier(t, 0.35, 0, 0.65, 1);
|
|
69
|
+
const tailEased = bgCubicBezier(t, 0.35, 0, 0.65, 0.6);
|
|
70
|
+
|
|
71
|
+
const headLength = headEased <= 0.75 ? (headEased / 0.75) * 360 : 360;
|
|
72
|
+
const tailOffset = tailEased <= 1 / 3 ? 0 : ((tailEased - 1 / 3) / (2 / 3)) * 360;
|
|
73
|
+
const arcLength = Math.max(0, headLength - tailOffset);
|
|
74
|
+
const containerDeg = containerEased * 360 + tailOffset;
|
|
75
|
+
|
|
76
|
+
return { containerDeg, arcLength };
|
|
77
|
+
}
|
|
78
|
+
|
|
48
79
|
function bgPieClipPath(size: number, angleDeg: number): string | undefined {
|
|
49
80
|
if (angleDeg >= 360) return undefined;
|
|
50
81
|
if (angleDeg <= 0) return 'path("M 0 0 Z")';
|
|
@@ -113,6 +144,7 @@ function sampleIndeterminate(t: number) {
|
|
|
113
144
|
// --- Animation constants ---
|
|
114
145
|
|
|
115
146
|
const INDETERMINATE_DURATION = 1200;
|
|
147
|
+
const INDETERMINATE_INITIAL_PHASE = 1 / 6;
|
|
116
148
|
const TRANSITION_DURATION = 300;
|
|
117
149
|
|
|
118
150
|
////////////////////////////////////////////////////////////////////////////////////
|
|
@@ -335,7 +367,10 @@ function IndeterminateRange({ numSize, classes }: { numSize: number; classes: Cl
|
|
|
335
367
|
if (!startTs) startTs = Number(ts);
|
|
336
368
|
|
|
337
369
|
const elapsed = ts - startTs;
|
|
338
|
-
const t =
|
|
370
|
+
const t =
|
|
371
|
+
((elapsed + INDETERMINATE_DURATION * INDETERMINATE_INITIAL_PHASE) %
|
|
372
|
+
INDETERMINATE_DURATION) /
|
|
373
|
+
INDETERMINATE_DURATION;
|
|
339
374
|
const state = sampleIndeterminate(t);
|
|
340
375
|
|
|
341
376
|
containerEl?.setStyleProperty("transform", `rotate(${state.containerDeg}deg)`);
|
|
@@ -373,7 +408,9 @@ function IndeterminateRange({ numSize, classes }: { numSize: number; classes: Cl
|
|
|
373
408
|
};
|
|
374
409
|
}, [numSize]);
|
|
375
410
|
|
|
376
|
-
const
|
|
411
|
+
const initialState = bgSampleIndeterminate(INDETERMINATE_INITIAL_PHASE);
|
|
412
|
+
const initialClipPath = bgPieClipPath(numSize, initialState.arcLength);
|
|
413
|
+
const initialHeadRad = (initialState.arcLength * Math.PI) / 180;
|
|
377
414
|
|
|
378
415
|
return (
|
|
379
416
|
<view
|
|
@@ -382,7 +419,7 @@ function IndeterminateRange({ numSize, classes }: { numSize: number; classes: Cl
|
|
|
382
419
|
position: "absolute",
|
|
383
420
|
width: `${numSize}px`,
|
|
384
421
|
height: `${numSize}px`,
|
|
385
|
-
transform:
|
|
422
|
+
transform: `rotate(${initialState.containerDeg}deg)`,
|
|
386
423
|
}}
|
|
387
424
|
>
|
|
388
425
|
<view
|
|
@@ -412,8 +449,8 @@ function IndeterminateRange({ numSize, classes }: { numSize: number; classes: Cl
|
|
|
412
449
|
style={{
|
|
413
450
|
width: `${capSize}px`,
|
|
414
451
|
height: `${capSize}px`,
|
|
415
|
-
left: `${halfSize - capSize / 2}px`,
|
|
416
|
-
top: `${halfSize - ringCenterR - capSize / 2}px`,
|
|
452
|
+
left: `${halfSize + ringCenterR * Math.sin(initialHeadRad) - capSize / 2}px`,
|
|
453
|
+
top: `${halfSize - ringCenterR * Math.cos(initialHeadRad) - capSize / 2}px`,
|
|
417
454
|
}}
|
|
418
455
|
/>
|
|
419
456
|
</view>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import { render } from "@lynx-js/react/testing-library";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { HStack, VStack } from "./Stack";
|
|
6
|
+
|
|
7
|
+
function getRenderedRoot() {
|
|
8
|
+
const root = elementTree.root;
|
|
9
|
+
|
|
10
|
+
if (!root) {
|
|
11
|
+
throw new Error("Expected Lynx render root to exist.");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const stack = root.querySelector<HTMLElement>("view");
|
|
15
|
+
|
|
16
|
+
if (!stack) {
|
|
17
|
+
throw new Error("Expected Stack root view to exist.");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return stack;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("Stack", () => {
|
|
24
|
+
it("maps a VStack gap token to row-gap", () => {
|
|
25
|
+
render(<VStack gap="x3" />);
|
|
26
|
+
|
|
27
|
+
expect(getRenderedRoot()).toHaveStyle({
|
|
28
|
+
rowGap: "var(--seed-dimension-x3)",
|
|
29
|
+
});
|
|
30
|
+
expect(getRenderedRoot().style.getPropertyValue("gap")).toBe("");
|
|
31
|
+
expect(getRenderedRoot().style.getPropertyValue("column-gap")).toBe("");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("maps an HStack gap token to column-gap", () => {
|
|
35
|
+
render(<HStack gap="x4" />);
|
|
36
|
+
|
|
37
|
+
expect(getRenderedRoot()).toHaveStyle({
|
|
38
|
+
columnGap: "var(--seed-dimension-x4)",
|
|
39
|
+
});
|
|
40
|
+
expect(getRenderedRoot().style.getPropertyValue("gap")).toBe("");
|
|
41
|
+
expect(getRenderedRoot().style.getPropertyValue("row-gap")).toBe("");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("keeps a numeric zero gap value", () => {
|
|
45
|
+
render(<VStack gap={0} />);
|
|
46
|
+
|
|
47
|
+
expect(getRenderedRoot()).toHaveStyle({ rowGap: "0px" });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keeps an arbitrary string gap value", () => {
|
|
51
|
+
render(<HStack gap="1.5rem" />);
|
|
52
|
+
|
|
53
|
+
expect(getRenderedRoot()).toHaveStyle({ columnGap: "1.5rem" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps style gap precedence over the gap prop", () => {
|
|
57
|
+
render(<VStack gap="x3" style={{ gap: "20px" }} />);
|
|
58
|
+
|
|
59
|
+
expect(getRenderedRoot()).toHaveStyle({ rowGap: "20px" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("keeps an axis longhand in style above the gap prop", () => {
|
|
63
|
+
render(<VStack gap="x3" style={{ rowGap: "24px" }} />);
|
|
64
|
+
|
|
65
|
+
expect(getRenderedRoot()).toHaveStyle({ rowGap: "24px" });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -54,11 +54,25 @@ function getStackProps(props: StackProps) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function useStackStyleProps(props: StackProps, flexDirection: "column" | "row") {
|
|
57
|
-
|
|
57
|
+
const stackStyleProps = useStyleProps({
|
|
58
58
|
display: "flex",
|
|
59
59
|
flexDirection,
|
|
60
60
|
...getStackProps(props),
|
|
61
61
|
});
|
|
62
|
+
// Lynx의 gap 단축 속성 파서는 var(...)를 0px로 확정하므로 축별 longhand를 사용합니다.
|
|
63
|
+
const { gap, ...style } = stackStyleProps.style;
|
|
64
|
+
|
|
65
|
+
if (gap == null) {
|
|
66
|
+
return stackStyleProps;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
...stackStyleProps,
|
|
71
|
+
style: {
|
|
72
|
+
[flexDirection === "column" ? "rowGap" : "columnGap"]: gap,
|
|
73
|
+
...style,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
function renderStackView(
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import type { ReactNode } from "@lynx-js/react";
|
|
3
|
+
import { render } from "@lynx-js/react/testing-library";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import * as TagGroup from "./TagGroup.namespace";
|
|
7
|
+
|
|
8
|
+
function getRenderedRoot() {
|
|
9
|
+
const root = elementTree.root;
|
|
10
|
+
|
|
11
|
+
if (!root) {
|
|
12
|
+
throw new Error("Expected Lynx render root to exist.");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return root;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function renderTagGroup(separator?: ReactNode) {
|
|
19
|
+
render(
|
|
20
|
+
<TagGroup.Root separator={separator}>
|
|
21
|
+
<TagGroup.Item>
|
|
22
|
+
<TagGroup.ItemLabel>첫 번째</TagGroup.ItemLabel>
|
|
23
|
+
</TagGroup.Item>
|
|
24
|
+
<TagGroup.Item>
|
|
25
|
+
<TagGroup.ItemLabel>두 번째</TagGroup.ItemLabel>
|
|
26
|
+
</TagGroup.Item>
|
|
27
|
+
<TagGroup.Item>
|
|
28
|
+
<TagGroup.ItemLabel>세 번째</TagGroup.ItemLabel>
|
|
29
|
+
</TagGroup.Item>
|
|
30
|
+
</TagGroup.Root>,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("TagGroup", () => {
|
|
35
|
+
it("trims surrounding whitespace from the default string separator", () => {
|
|
36
|
+
renderTagGroup();
|
|
37
|
+
|
|
38
|
+
const separators = getRenderedRoot().querySelectorAll(".seed-tag-group__separator");
|
|
39
|
+
|
|
40
|
+
expect(separators).toHaveLength(2);
|
|
41
|
+
expect(Array.from(separators, (separator) => separator.textContent)).toEqual(["·", "·"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("trims surrounding whitespace from a custom string separator", () => {
|
|
45
|
+
renderTagGroup(" \n / \t ");
|
|
46
|
+
|
|
47
|
+
const separators = getRenderedRoot().querySelectorAll(".seed-tag-group__separator");
|
|
48
|
+
|
|
49
|
+
expect(Array.from(separators, (separator) => separator.textContent)).toEqual(["/", "/"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("preserves a non-string separator node", () => {
|
|
53
|
+
renderTagGroup(<text className="custom-separator"> 구분 </text>);
|
|
54
|
+
|
|
55
|
+
const customSeparators = getRenderedRoot().querySelectorAll(".custom-separator");
|
|
56
|
+
|
|
57
|
+
expect(customSeparators).toHaveLength(2);
|
|
58
|
+
expect(Array.from(customSeparators, (separator) => separator.textContent)).toEqual([
|
|
59
|
+
" 구분 ",
|
|
60
|
+
" 구분 ",
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("keeps each separator and following item in the same wrapper", () => {
|
|
65
|
+
renderTagGroup();
|
|
66
|
+
|
|
67
|
+
const wrappers = getRenderedRoot().querySelectorAll(".seed-tag-group__separatorWrapper");
|
|
68
|
+
|
|
69
|
+
expect(wrappers).toHaveLength(2);
|
|
70
|
+
expect(wrappers[0]?.textContent).toBe("·두 번째");
|
|
71
|
+
expect(wrappers[1]?.textContent).toBe("·세 번째");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -31,7 +31,8 @@ export interface TagGroupRootProps
|
|
|
31
31
|
TagGroupItemVariantProps,
|
|
32
32
|
LynxStyledElementProps {
|
|
33
33
|
/**
|
|
34
|
-
* children 사이에 삽입되는 구분자. 기본값 `"
|
|
34
|
+
* children 사이에 삽입되는 구분자. 기본값 `"·"`.
|
|
35
|
+
* 문자열을 전달하면 앞뒤 공백을 제거합니다.
|
|
35
36
|
*/
|
|
36
37
|
separator?: React.ReactNode;
|
|
37
38
|
}
|
|
@@ -41,6 +42,7 @@ export const TagGroupRoot = React.forwardRef<unknown, TagGroupRootProps>((props,
|
|
|
41
42
|
splitMultipleVariantsProps(props, { tagGroup, tagGroupItem });
|
|
42
43
|
const { children, className, separator = " · ", ...nativeProps } = otherProps;
|
|
43
44
|
const classes = tagGroup(tagGroupVariantProps);
|
|
45
|
+
const normalizedSeparator = typeof separator === "string" ? separator.trim() : separator;
|
|
44
46
|
|
|
45
47
|
const visibleChildren = toArray(children);
|
|
46
48
|
|
|
@@ -61,7 +63,7 @@ export const TagGroupRoot = React.forwardRef<unknown, TagGroupRootProps>((props,
|
|
|
61
63
|
key={(child as React.ReactElement).key ?? index}
|
|
62
64
|
className={classes.separatorWrapper}
|
|
63
65
|
>
|
|
64
|
-
<text className={classes.separator}>{
|
|
66
|
+
<text className={classes.separator}>{normalizedSeparator}</text>
|
|
65
67
|
{child}
|
|
66
68
|
</view>
|
|
67
69
|
);
|
|
@@ -63,11 +63,17 @@ function getTextFieldRoot() {
|
|
|
63
63
|
return textFieldRoot;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
function fireNativeEvent(
|
|
66
|
+
function fireNativeEvent(
|
|
67
|
+
nativeRef: NodesRef,
|
|
68
|
+
element: Element,
|
|
69
|
+
eventName: string,
|
|
70
|
+
detail: object,
|
|
71
|
+
bubbles = true,
|
|
72
|
+
) {
|
|
67
73
|
const EventConstructor = element.ownerDocument.defaultView?.CustomEvent;
|
|
68
74
|
if (!EventConstructor) throw new Error("Expected CustomEvent constructor to exist.");
|
|
69
75
|
|
|
70
|
-
const event = new EventConstructor(`bindEvent:${eventName}`, { bubbles
|
|
76
|
+
const event = new EventConstructor(`bindEvent:${eventName}`, { bubbles, detail });
|
|
71
77
|
Object.assign(event, { eventType: "bindEvent", eventName });
|
|
72
78
|
fireEvent(nativeRef as unknown as Element, event);
|
|
73
79
|
}
|
|
@@ -171,7 +177,9 @@ describe("TextField", () => {
|
|
|
171
177
|
expect(input).toHaveAttribute("name", "title");
|
|
172
178
|
expect(input).toHaveAttribute("placeholder", "제목");
|
|
173
179
|
expect(input).toHaveAttribute("default-value", "초기값");
|
|
174
|
-
expect(input).
|
|
180
|
+
expect(input).toHaveAttribute("show-soft-input-on-focus", "true");
|
|
181
|
+
expect(input).toHaveAttribute("android-set-soft-input-mode", "unspecified");
|
|
182
|
+
expect(input).not.toHaveAttribute("maxlength");
|
|
175
183
|
|
|
176
184
|
rerender(renderTextField());
|
|
177
185
|
|
|
@@ -196,6 +204,56 @@ describe("TextField", () => {
|
|
|
196
204
|
expect(input).toHaveAttribute("default-value", "");
|
|
197
205
|
});
|
|
198
206
|
|
|
207
|
+
it("replaces undefined soft keyboard props with safe native defaults", () => {
|
|
208
|
+
const { rerender } = render(
|
|
209
|
+
<TextField.Root>
|
|
210
|
+
<TextField.Input
|
|
211
|
+
show-soft-input-on-focus={undefined}
|
|
212
|
+
android-set-soft-input-mode={undefined}
|
|
213
|
+
/>
|
|
214
|
+
</TextField.Root>,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const input = getRenderedRoot().querySelector("input");
|
|
218
|
+
expect(input).toHaveAttribute("show-soft-input-on-focus", "true");
|
|
219
|
+
expect(input).toHaveAttribute("android-set-soft-input-mode", "unspecified");
|
|
220
|
+
|
|
221
|
+
rerender(
|
|
222
|
+
<TextField.Root>
|
|
223
|
+
<TextField.Textarea
|
|
224
|
+
show-soft-input-on-focus={undefined}
|
|
225
|
+
android-set-soft-input-mode={undefined}
|
|
226
|
+
/>
|
|
227
|
+
</TextField.Root>,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const textarea = getRenderedRoot().querySelector("textarea");
|
|
231
|
+
expect(textarea).toHaveAttribute("show-soft-input-on-focus", "true");
|
|
232
|
+
expect(textarea).toHaveAttribute("android-set-soft-input-mode", "unspecified");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("preserves explicit soft keyboard overrides", () => {
|
|
236
|
+
const { rerender } = render(
|
|
237
|
+
<TextField.Root>
|
|
238
|
+
<TextField.Input show-soft-input-on-focus={false} android-set-soft-input-mode="nothing" />
|
|
239
|
+
</TextField.Root>,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const input = getRenderedRoot().querySelector("input");
|
|
243
|
+
expect(input).toHaveAttribute("show-soft-input-on-focus", "false");
|
|
244
|
+
expect(input).toHaveAttribute("android-set-soft-input-mode", "nothing");
|
|
245
|
+
|
|
246
|
+
rerender(
|
|
247
|
+
<TextField.Root>
|
|
248
|
+
<TextField.Textarea show-soft-input-on-focus={false} android-set-soft-input-mode="resize" />
|
|
249
|
+
</TextField.Root>,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const textarea = getRenderedRoot().querySelector("textarea");
|
|
253
|
+
expect(textarea).toHaveAttribute("show-soft-input-on-focus", "false");
|
|
254
|
+
expect(textarea).toHaveAttribute("android-set-soft-input-mode", "resize");
|
|
255
|
+
});
|
|
256
|
+
|
|
199
257
|
it("initializes a non-empty value without invoking a native UI method", () => {
|
|
200
258
|
const invoke = vi.fn(() => ({ exec: vi.fn() }));
|
|
201
259
|
const setRef = (node: NodesRef | null) => {
|
|
@@ -543,8 +601,9 @@ describe("TextField", () => {
|
|
|
543
601
|
);
|
|
544
602
|
|
|
545
603
|
if (!inputRef.current) throw new Error("Expected native input ref to exist.");
|
|
546
|
-
expect(getRenderedRoot().querySelector("input")).
|
|
604
|
+
expect(getRenderedRoot().querySelector("input")).toHaveAttribute(
|
|
547
605
|
"android-set-soft-input-mode",
|
|
606
|
+
"unspecified",
|
|
548
607
|
);
|
|
549
608
|
|
|
550
609
|
// ReactLynx Testing Library runtime accepts NodesRef, but its public fireEvent type
|
|
@@ -627,8 +686,11 @@ describe("TextField", () => {
|
|
|
627
686
|
expect(getRenderedQueries().queryByText("secret")).toBeNull();
|
|
628
687
|
});
|
|
629
688
|
|
|
630
|
-
it("uses native
|
|
689
|
+
it("uses direct native autoresize on Android", () => {
|
|
690
|
+
setSystemInfo({ platform: "Android" });
|
|
691
|
+
|
|
631
692
|
const textareaRef = createRef<NodesRef>();
|
|
693
|
+
const bindlayoutchange = vi.fn();
|
|
632
694
|
const actions = {
|
|
633
695
|
focus: vi.fn(),
|
|
634
696
|
blur: vi.fn(),
|
|
@@ -642,6 +704,7 @@ describe("TextField", () => {
|
|
|
642
704
|
ref={textareaRef}
|
|
643
705
|
placeholder="내용"
|
|
644
706
|
android-set-soft-input-mode="nothing"
|
|
707
|
+
bindlayoutchange={bindlayoutchange}
|
|
645
708
|
/>
|
|
646
709
|
</TextField.Root>
|
|
647
710
|
</KeyboardAvoidanceActionsContext.Provider>,
|
|
@@ -649,37 +712,27 @@ describe("TextField", () => {
|
|
|
649
712
|
|
|
650
713
|
const root = getRenderedRoot();
|
|
651
714
|
const textarea = root.querySelector("textarea");
|
|
652
|
-
const textareaRoot = root.querySelector(".seed-text-input__textareaRoot");
|
|
653
715
|
if (!textarea) throw new Error("Expected native textarea to exist.");
|
|
654
|
-
if (!textareaRoot) throw new Error("Expected textarea sizing wrapper to exist.");
|
|
655
716
|
if (!textareaRef.current) throw new Error("Expected native textarea ref to exist.");
|
|
656
717
|
|
|
657
|
-
expect(
|
|
658
|
-
expect(textarea).toHaveClass("seed-text-
|
|
659
|
-
expect(textarea).not.toHaveClass("seed-text-input__textareaAndroidAutoresize--size_large");
|
|
718
|
+
expect(textarea).not.toHaveClass("seed-text-input__textareaNativeAutoresize");
|
|
719
|
+
expect(textarea).toHaveClass("seed-text-input__textareaAndroidAutoresize--size_large");
|
|
660
720
|
expect(textarea).toHaveClass("seed-text-input__textareaValue");
|
|
661
721
|
expect(textarea).not.toHaveClass("seed-text-input__textareaFixed");
|
|
662
722
|
expect(textarea).toHaveAttribute("bounces", "false");
|
|
723
|
+
expect(textarea).toHaveAttribute("show-soft-input-on-focus", "true");
|
|
663
724
|
expect(textarea).toHaveAttribute("default-value", "첫 줄\n둘째 줄\n");
|
|
664
725
|
expect(textarea).toHaveAttribute("android-fullscreen-mode", "false");
|
|
665
726
|
expect(textarea).toHaveAttribute("android-set-soft-input-mode", "nothing");
|
|
666
|
-
expect(
|
|
727
|
+
expect(textarea).not.toHaveAttribute("maxlength");
|
|
728
|
+
expect(root.querySelector(".seed-text-input__textareaRoot")).toBeNull();
|
|
667
729
|
|
|
668
|
-
fireNativeEvent(
|
|
730
|
+
fireNativeEvent(textareaRef.current, textarea, "layoutchange", {
|
|
669
731
|
width: 200,
|
|
670
732
|
height: 120,
|
|
671
733
|
});
|
|
672
734
|
expect(actions.layoutChanged).toHaveBeenCalledOnce();
|
|
673
|
-
|
|
674
|
-
const exec = vi.fn();
|
|
675
|
-
const invoke = vi.fn(() => ({ exec }));
|
|
676
|
-
textareaRef.current.invoke = invoke as unknown as NodesRef["invoke"];
|
|
677
|
-
fireEvent.tap(textareaRef.current as unknown as Element);
|
|
678
|
-
expect(invoke).not.toHaveBeenCalled();
|
|
679
|
-
|
|
680
|
-
fireEvent.tap(textareaRoot);
|
|
681
|
-
expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ method: "focus" }));
|
|
682
|
-
expect(exec).toHaveBeenCalledOnce();
|
|
735
|
+
expect(bindlayoutchange).toHaveBeenCalledOnce();
|
|
683
736
|
});
|
|
684
737
|
|
|
685
738
|
it("uses native intrinsic autoresize with the Android-only line-spacing correction", () => {
|
|
@@ -695,7 +748,7 @@ describe("TextField", () => {
|
|
|
695
748
|
const textarea = root.querySelector("textarea");
|
|
696
749
|
if (!textarea) throw new Error("Expected native textarea to exist.");
|
|
697
750
|
|
|
698
|
-
expect(textarea).toHaveClass("seed-text-input__textareaNativeAutoresize");
|
|
751
|
+
expect(textarea).not.toHaveClass("seed-text-input__textareaNativeAutoresize");
|
|
699
752
|
expect(textarea).toHaveClass("seed-text-input__textareaAndroidAutoresize--size_large");
|
|
700
753
|
expect(textarea).toHaveAttribute("line-spacing", "3.2px");
|
|
701
754
|
expect(root.querySelector(".seed-text-input__textareaAutoresizeRoot--size_large")).toBeNull();
|
|
@@ -743,29 +796,98 @@ describe("TextField", () => {
|
|
|
743
796
|
expect(uncorrectedTextarea).toHaveAttribute("line-spacing", "0");
|
|
744
797
|
});
|
|
745
798
|
|
|
746
|
-
it("
|
|
799
|
+
it("uses an iOS-only sizing wrapper without intercepting native textarea taps", () => {
|
|
747
800
|
setSystemInfo({ platform: "iOS" });
|
|
748
801
|
|
|
802
|
+
const textareaRef = createRef<NodesRef>();
|
|
803
|
+
const bindlayoutchange = vi.fn();
|
|
804
|
+
const actions = {
|
|
805
|
+
focus: vi.fn(),
|
|
806
|
+
blur: vi.fn(),
|
|
807
|
+
layoutChanged: vi.fn(),
|
|
808
|
+
unregister: vi.fn(),
|
|
809
|
+
};
|
|
749
810
|
render(
|
|
750
|
-
<
|
|
751
|
-
<TextField.
|
|
752
|
-
|
|
811
|
+
<KeyboardAvoidanceActionsContext.Provider value={actions}>
|
|
812
|
+
<TextField.Root defaultValue={"첫 줄\n"}>
|
|
813
|
+
<TextField.Textarea ref={textareaRef} bindlayoutchange={bindlayoutchange} />
|
|
814
|
+
</TextField.Root>
|
|
815
|
+
</KeyboardAvoidanceActionsContext.Provider>,
|
|
753
816
|
);
|
|
754
817
|
|
|
755
818
|
const root = getRenderedRoot();
|
|
756
819
|
const textarea = root.querySelector("textarea");
|
|
757
|
-
|
|
820
|
+
const textareaRoot = root.querySelector(".seed-text-input__textareaRoot");
|
|
821
|
+
if (!textarea || !textareaRoot || !textareaRef.current) {
|
|
822
|
+
throw new Error("Expected native textarea and its iOS sizing wrapper to exist.");
|
|
823
|
+
}
|
|
758
824
|
|
|
759
825
|
expect(textarea).toHaveClass("seed-text-input__textareaNativeAutoresize");
|
|
760
826
|
expect(textarea).not.toHaveClass("seed-text-input__textareaAndroidAutoresize--size_large");
|
|
761
827
|
expect(textarea).not.toHaveAttribute("line-spacing");
|
|
762
|
-
expect(
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
828
|
+
expect(textareaRoot).toHaveClass("seed-text-input__textareaAutoresizeRoot--size_large");
|
|
829
|
+
expect(textareaRoot).toHaveAttribute("ignore-focus", "true");
|
|
830
|
+
|
|
831
|
+
const exec = vi.fn();
|
|
832
|
+
const invoke = vi.fn(() => ({ exec }));
|
|
833
|
+
textareaRef.current.invoke = invoke as unknown as NodesRef["invoke"];
|
|
834
|
+
|
|
835
|
+
fireEvent.tap(textareaRef.current as unknown as Element);
|
|
836
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
837
|
+
|
|
838
|
+
fireEvent.tap(textareaRoot);
|
|
839
|
+
expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ method: "focus" }));
|
|
840
|
+
expect(exec).toHaveBeenCalledOnce();
|
|
841
|
+
|
|
842
|
+
actions.layoutChanged.mockClear();
|
|
843
|
+
fireNativeEvent(
|
|
844
|
+
textareaRef.current,
|
|
845
|
+
textarea,
|
|
846
|
+
"layoutchange",
|
|
847
|
+
{
|
|
848
|
+
width: 200,
|
|
849
|
+
height: 80,
|
|
850
|
+
},
|
|
851
|
+
false,
|
|
852
|
+
);
|
|
853
|
+
expect(bindlayoutchange).toHaveBeenCalledOnce();
|
|
854
|
+
expect(actions.layoutChanged).not.toHaveBeenCalled();
|
|
855
|
+
|
|
856
|
+
fireNativeEvent(textareaRoot as unknown as NodesRef, textareaRoot, "layoutchange", {
|
|
857
|
+
width: 200,
|
|
858
|
+
height: 94,
|
|
859
|
+
});
|
|
860
|
+
expect(actions.layoutChanged).toHaveBeenCalledOnce();
|
|
766
861
|
});
|
|
767
862
|
|
|
768
|
-
it("does not
|
|
863
|
+
it("does not invoke focus from disabled iOS textarea wrapper padding", () => {
|
|
864
|
+
setSystemInfo({ platform: "iOS" });
|
|
865
|
+
|
|
866
|
+
const textareaRef = createRef<NodesRef>();
|
|
867
|
+
render(
|
|
868
|
+
<TextField.Root disabled>
|
|
869
|
+
<TextField.Textarea ref={textareaRef} />
|
|
870
|
+
</TextField.Root>,
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
const textareaRoot = getRenderedRoot().querySelector(".seed-text-input__textareaRoot");
|
|
874
|
+
if (!textareaRoot || !textareaRef.current) {
|
|
875
|
+
throw new Error("Expected disabled native textarea and its iOS sizing wrapper to exist.");
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const invoke = vi.fn(() => ({ exec: vi.fn() }));
|
|
879
|
+
textareaRef.current.invoke = invoke as unknown as NodesRef["invoke"];
|
|
880
|
+
|
|
881
|
+
fireEvent.tap(textareaRoot);
|
|
882
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it.each([
|
|
886
|
+
"Android",
|
|
887
|
+
"iOS",
|
|
888
|
+
] as const)("does not restore the previous value after an accepted controlled textarea newline on %s", async (platform) => {
|
|
889
|
+
setSystemInfo({ platform });
|
|
890
|
+
|
|
769
891
|
const textareaRef = createRef<NodesRef>();
|
|
770
892
|
const onValueChange = vi.fn();
|
|
771
893
|
const bindinput = vi.fn();
|
|
@@ -794,8 +916,13 @@ describe("TextField", () => {
|
|
|
794
916
|
throw new Error("Expected native textarea to exist.");
|
|
795
917
|
}
|
|
796
918
|
|
|
797
|
-
|
|
798
|
-
|
|
919
|
+
if (platform === "iOS") {
|
|
920
|
+
expect(textarea).toHaveClass("seed-text-input__textareaNativeAutoresize");
|
|
921
|
+
expect(root.querySelector(".seed-text-input__textareaRoot")).not.toBeNull();
|
|
922
|
+
} else {
|
|
923
|
+
expect(textarea).not.toHaveClass("seed-text-input__textareaNativeAutoresize");
|
|
924
|
+
expect(root.querySelector(".seed-text-input__textareaRoot")).toBeNull();
|
|
925
|
+
}
|
|
799
926
|
|
|
800
927
|
const invoke = vi.fn(() => ({ exec: vi.fn() }));
|
|
801
928
|
textareaRef.current.invoke = invoke as unknown as NodesRef["invoke"];
|
|
@@ -811,6 +938,7 @@ describe("TextField", () => {
|
|
|
811
938
|
expect(invoke).not.toHaveBeenCalled();
|
|
812
939
|
|
|
813
940
|
await flushControlledReconciliation();
|
|
941
|
+
expect(getRenderedRoot().querySelector("textarea")).toBe(textarea);
|
|
814
942
|
expect(invoke).not.toHaveBeenCalled();
|
|
815
943
|
});
|
|
816
944
|
|
|
@@ -17,13 +17,21 @@ declare const SystemInfo: LynxSystemInfo | undefined;
|
|
|
17
17
|
|
|
18
18
|
const ANDROID_TEXTAREA_DEFAULT_LINE_SPACING = "3.2px" as const;
|
|
19
19
|
|
|
20
|
-
function
|
|
20
|
+
function getRuntimePlatform(): string | undefined {
|
|
21
21
|
const globalSystemInfo = (globalThis as typeof globalThis & { SystemInfo?: LynxSystemInfo })
|
|
22
22
|
.SystemInfo;
|
|
23
23
|
const systemInfo =
|
|
24
24
|
globalSystemInfo ?? (typeof SystemInfo === "undefined" ? undefined : SystemInfo);
|
|
25
25
|
|
|
26
|
-
return systemInfo?.platform
|
|
26
|
+
return systemInfo?.platform;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isAndroidRuntime(): boolean {
|
|
30
|
+
return getRuntimePlatform() === "Android";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isIOSRuntime(): boolean {
|
|
34
|
+
return getRuntimePlatform() === "iOS";
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
const { ClassNamesProvider, useClassNames } = createSlotRecipeContext(textInput);
|
|
@@ -562,13 +570,22 @@ export interface TextFieldInputProps extends NativeTextControlProps {
|
|
|
562
570
|
maxlength?: NativeInputProps["maxlength"];
|
|
563
571
|
readonly?: NativeInputProps["readonly"];
|
|
564
572
|
disabled?: NativeInputProps["disabled"];
|
|
573
|
+
/**
|
|
574
|
+
* 포커스할 때 시스템 키보드를 표시한다.
|
|
575
|
+
* `undefined`가 native attribute로 전달되지 않도록 `true`를 명시적으로 적용한다.
|
|
576
|
+
* @defaultValue true
|
|
577
|
+
*/
|
|
565
578
|
"show-soft-input-on-focus"?: NativeInputProps["show-soft-input-on-focus"];
|
|
566
579
|
"input-filter"?: NativeInputProps["input-filter"];
|
|
567
580
|
type?: NativeInputProps["type"];
|
|
568
581
|
"ios-auto-correct"?: NativeInputProps["ios-auto-correct"];
|
|
569
582
|
"ios-spell-check"?: NativeInputProps["ios-spell-check"];
|
|
570
583
|
"android-fullscreen-mode"?: NativeInputProps["android-fullscreen-mode"];
|
|
571
|
-
/**
|
|
584
|
+
/**
|
|
585
|
+
* Android host window의 soft input mode를 지정한다.
|
|
586
|
+
* `undefined`가 native attribute로 전달되지 않도록 `"unspecified"`를 명시적으로 적용한다.
|
|
587
|
+
* @defaultValue "unspecified"
|
|
588
|
+
*/
|
|
572
589
|
"android-set-soft-input-mode"?: AndroidSetSoftInputMode;
|
|
573
590
|
bindfocus?: NativeInputProps["bindfocus"];
|
|
574
591
|
bindblur?: NativeInputProps["bindblur"];
|
|
@@ -651,10 +668,10 @@ export const TextFieldInput = React.forwardRef<NodesRef, TextFieldInputProps>((p
|
|
|
651
668
|
className={clsx(classes.value, className)}
|
|
652
669
|
disabled={control.disabled}
|
|
653
670
|
readonly={control.readOnly}
|
|
654
|
-
show-soft-input-on-focus={
|
|
655
|
-
android-set-soft-input-mode={androidSetSoftInputMode}
|
|
671
|
+
show-soft-input-on-focus={showSoftInputOnFocus ?? true}
|
|
672
|
+
android-set-soft-input-mode={androidSetSoftInputMode ?? "unspecified"}
|
|
656
673
|
name={name ?? control.context.name}
|
|
657
|
-
|
|
674
|
+
{...(resolvedMaxLength === undefined ? {} : { maxlength: resolvedMaxLength })}
|
|
658
675
|
bindinput={control.handleInput}
|
|
659
676
|
bindselection={handleSelection}
|
|
660
677
|
bindfocus={control.handleFocus}
|
|
@@ -686,6 +703,11 @@ export interface TextFieldTextareaProps extends NativeTextControlProps {
|
|
|
686
703
|
"line-spacing"?: NativeTextareaProps["line-spacing"];
|
|
687
704
|
readonly?: NativeTextareaProps["readonly"];
|
|
688
705
|
disabled?: NativeTextareaProps["disabled"];
|
|
706
|
+
/**
|
|
707
|
+
* 포커스할 때 시스템 키보드를 표시한다.
|
|
708
|
+
* `undefined`가 native attribute로 전달되지 않도록 `true`를 명시적으로 적용한다.
|
|
709
|
+
* @defaultValue true
|
|
710
|
+
*/
|
|
689
711
|
"show-soft-input-on-focus"?: NativeTextareaProps["show-soft-input-on-focus"];
|
|
690
712
|
"input-filter"?: NativeTextareaProps["input-filter"];
|
|
691
713
|
"enable-scroll-bar"?: NativeTextareaProps["enable-scroll-bar"];
|
|
@@ -694,7 +716,11 @@ export interface TextFieldTextareaProps extends NativeTextControlProps {
|
|
|
694
716
|
"ios-spell-check"?: NativeTextareaProps["ios-spell-check"];
|
|
695
717
|
/** Android의 fullscreen extract input을 활성화한다. @defaultValue false */
|
|
696
718
|
"android-fullscreen-mode"?: NativeTextareaProps["android-fullscreen-mode"];
|
|
697
|
-
/**
|
|
719
|
+
/**
|
|
720
|
+
* Android host window의 soft input mode를 지정한다.
|
|
721
|
+
* `undefined`가 native attribute로 전달되지 않도록 `"unspecified"`를 명시적으로 적용한다.
|
|
722
|
+
* @defaultValue "unspecified"
|
|
723
|
+
*/
|
|
698
724
|
"android-set-soft-input-mode"?: AndroidSetSoftInputMode;
|
|
699
725
|
bindfocus?: NativeTextareaProps["bindfocus"];
|
|
700
726
|
bindblur?: NativeTextareaProps["bindblur"];
|
|
@@ -738,6 +764,7 @@ export const TextFieldTextarea = React.forwardRef<NodesRef, TextFieldTextareaPro
|
|
|
738
764
|
control.nativeInsertionMaxLength,
|
|
739
765
|
);
|
|
740
766
|
const isAndroid = isAndroidRuntime();
|
|
767
|
+
const usesIOSAutoresizeWrapper = autoresize && isIOSRuntime();
|
|
741
768
|
// Android native textarea는 CSS line-height를 무시한다. 실기기에서 관측한
|
|
742
769
|
// native font metrics와 SEED line box의 차이를 line-spacing으로 보정한다.
|
|
743
770
|
// 명시적인 값(0 포함)은 내부 기본값보다 우선한다.
|
|
@@ -756,16 +783,29 @@ export const TextFieldTextarea = React.forwardRef<NodesRef, TextFieldTextareaPro
|
|
|
756
783
|
},
|
|
757
784
|
[bindselection, control.handleSelectionChange],
|
|
758
785
|
);
|
|
786
|
+
const handleLayoutChange = React.useCallback<
|
|
787
|
+
NonNullable<NativeTextareaProps["bindlayoutchange"]>
|
|
788
|
+
>(
|
|
789
|
+
(event) => {
|
|
790
|
+
"background only";
|
|
791
|
+
|
|
792
|
+
if (autoresize) {
|
|
793
|
+
control.notifyLayoutChanged();
|
|
794
|
+
}
|
|
795
|
+
bindlayoutchange?.(event);
|
|
796
|
+
},
|
|
797
|
+
[autoresize, bindlayoutchange, control.notifyLayoutChanged],
|
|
798
|
+
);
|
|
759
799
|
const handleTextareaRootTap = React.useCallback<
|
|
760
800
|
NonNullable<IntrinsicElements["view"]["bindtap"]>
|
|
761
801
|
>(
|
|
762
802
|
(event) => {
|
|
763
803
|
"background only";
|
|
764
804
|
|
|
765
|
-
if (event.target.uid !== event.currentTarget.uid) return;
|
|
805
|
+
if (control.disabled || event.target.uid !== event.currentTarget.uid) return;
|
|
766
806
|
control.focusNativeControl();
|
|
767
807
|
},
|
|
768
|
-
[control.focusNativeControl],
|
|
808
|
+
[control.disabled, control.focusNativeControl],
|
|
769
809
|
);
|
|
770
810
|
|
|
771
811
|
if (control.readOnly) {
|
|
@@ -795,8 +835,8 @@ export const TextFieldTextarea = React.forwardRef<NodesRef, TextFieldTextareaPro
|
|
|
795
835
|
);
|
|
796
836
|
}
|
|
797
837
|
|
|
798
|
-
//
|
|
799
|
-
//
|
|
838
|
+
// iOS textarea는 첫 편집에서 native contentSize를 높이에 반영한다.
|
|
839
|
+
// 디자인 padding을 wrapper로 분리해 이때 padding만큼 높이가 중복되지 않게 한다.
|
|
800
840
|
const textarea = (
|
|
801
841
|
<textarea
|
|
802
842
|
{...control.defaultValueProps}
|
|
@@ -804,33 +844,35 @@ export const TextFieldTextarea = React.forwardRef<NodesRef, TextFieldTextareaPro
|
|
|
804
844
|
className={clsx(
|
|
805
845
|
classes.value,
|
|
806
846
|
classes.textareaValue,
|
|
807
|
-
autoresize
|
|
808
|
-
|
|
847
|
+
!autoresize && classes.textareaFixed,
|
|
848
|
+
usesIOSAutoresizeWrapper && classes.textareaNativeAutoresize,
|
|
849
|
+
autoresize && !usesIOSAutoresizeWrapper && classes.textareaAndroidAutoresize,
|
|
809
850
|
className,
|
|
810
851
|
)}
|
|
811
852
|
disabled={control.disabled}
|
|
812
853
|
readonly={control.readOnly}
|
|
813
|
-
show-soft-input-on-focus={
|
|
854
|
+
show-soft-input-on-focus={showSoftInputOnFocus ?? true}
|
|
814
855
|
bounces={bounces ?? (autoresize ? false : undefined)}
|
|
815
856
|
line-spacing={resolvedLineSpacing}
|
|
816
857
|
android-fullscreen-mode={androidFullscreenMode ?? false}
|
|
817
|
-
android-set-soft-input-mode={androidSetSoftInputMode}
|
|
858
|
+
android-set-soft-input-mode={androidSetSoftInputMode ?? "unspecified"}
|
|
818
859
|
name={name ?? control.context.name}
|
|
819
|
-
|
|
860
|
+
{...(resolvedMaxLength === undefined ? {} : { maxlength: resolvedMaxLength })}
|
|
820
861
|
bindinput={control.handleInput}
|
|
821
862
|
bindselection={handleSelection}
|
|
822
863
|
bindfocus={control.handleFocus}
|
|
823
864
|
bindblur={control.handleBlur}
|
|
824
|
-
bindlayoutchange={bindlayoutchange}
|
|
865
|
+
bindlayoutchange={usesIOSAutoresizeWrapper ? bindlayoutchange : handleLayoutChange}
|
|
825
866
|
{...nativeProps}
|
|
826
867
|
/>
|
|
827
868
|
);
|
|
828
869
|
|
|
829
|
-
if (!
|
|
870
|
+
if (!usesIOSAutoresizeWrapper) return textarea;
|
|
830
871
|
|
|
831
872
|
return (
|
|
832
873
|
<view
|
|
833
|
-
|
|
874
|
+
ignore-focus={true}
|
|
875
|
+
className={clsx(classes.textareaRoot, classes.textareaAutoresizeRoot)}
|
|
834
876
|
bindtap={handleTextareaRootTap}
|
|
835
877
|
bindlayoutchange={control.notifyLayoutChanged}
|
|
836
878
|
>
|