@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.
@@ -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;AA2GvE,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"}
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 initialClipPath = "path(\"M 0 0 Z\")";
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: "rotate(0deg)"
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;AA0ClD,eAAO,MAAM,MAAM,6EAIjB,CAAC;AAIH,eAAO,MAAM,MAAM,6EAIjB,CAAC"}
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
- return useStyleProps({
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;;OAEG;IACH,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC7B;AAED,eAAO,MAAM,YAAY,mFAiCvB,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"}
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}>{separator}</text>
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
- /** Android host window의 soft input mode를 지정한다. */
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
- /** Android host window의 soft input mode를 지정한다. */
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":"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"}
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 (globalThis.SystemInfo ?? (typeof SystemInfo === "undefined" ? void 0 : SystemInfo))?.platform === "Android";
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={control.readOnly ? false : showSoftInputOnFocus} android-set-soft-input-mode={androidSetSoftInputMode} name={name ?? control.context.name} maxlength={resolvedMaxLength} bindinput={control.handleInput} bindselection={handleSelection} bindfocus={control.handleFocus} bindblur={control.handleBlur} {...nativeProps} />;
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 ? classes.textareaNativeAutoresize : classes.textareaFixed, autoresize && isAndroid && classes.textareaAndroidAutoresize, className)} disabled={control.disabled} readonly={control.readOnly} show-soft-input-on-focus={control.readOnly ? false : showSoftInputOnFocus} bounces={bounces ?? (autoresize ? false : void 0)} line-spacing={resolvedLineSpacing} android-fullscreen-mode={androidFullscreenMode ?? false} android-set-soft-input-mode={androidSetSoftInputMode} name={name ?? control.context.name} maxlength={resolvedMaxLength} bindinput={control.handleInput} bindselection={handleSelection} bindfocus={control.handleFocus} bindblur={control.handleBlur} bindlayoutchange={bindlayoutchange} {...nativeProps} />;
383
- if (!autoresize) return textarea;
384
- return <view className={clsx(classes.textareaRoot, !isAndroid && classes.textareaAutoresizeRoot)} bindtap={handleTextareaRootTap} bindlayoutchange={control.notifyLayoutChanged}>
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.0",
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.1.0 <1.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.0",
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 = (elapsed % INDETERMINATE_DURATION) / INDETERMINATE_DURATION;
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 initialClipPath = 'path("M 0 0 Z")';
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: "rotate(0deg)",
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
- return useStyleProps({
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}>{separator}</text>
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(nativeRef: NodesRef, element: Element, eventName: string, detail: object) {
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: true, detail });
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).not.toHaveAttribute("android-set-soft-input-mode");
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")).not.toHaveAttribute(
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 intrinsic autoresize inside a padded sizing wrapper", () => {
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(textareaRoot).toHaveClass("seed-text-input__textareaAutoresizeRoot--size_large");
658
- expect(textarea).toHaveClass("seed-text-input__textareaNativeAutoresize");
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(textareaRoot.querySelector("text")).toBeNull();
727
+ expect(textarea).not.toHaveAttribute("maxlength");
728
+ expect(root.querySelector(".seed-text-input__textareaRoot")).toBeNull();
667
729
 
668
- fireNativeEvent(textareaRoot as unknown as NodesRef, textareaRoot, "layoutchange", {
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("keeps iOS native autoresize without the Android line-spacing correction", () => {
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
- <TextField.Root defaultValue={"첫 줄\n"}>
751
- <TextField.Textarea />
752
- </TextField.Root>,
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
- if (!textarea) throw new Error("Expected native textarea to exist.");
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
- root.querySelector(".seed-text-input__textareaAutoresizeRoot--size_large"),
764
- ).not.toBeNull();
765
- expect(root.querySelector(".seed-text-input__textareaRoot text")).toBeNull();
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 restore the previous value after an accepted controlled textarea newline", async () => {
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
- expect(textarea).toHaveClass("seed-text-input__textareaNativeAutoresize");
798
- expect(root.querySelector(".seed-text-input__textareaRoot")).not.toBeNull();
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 isAndroidRuntime(): boolean {
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 === "Android";
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
- /** Android host window의 soft input mode를 지정한다. */
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={control.readOnly ? false : showSoftInputOnFocus}
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
- maxlength={resolvedMaxLength}
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
- /** Android host window의 soft input mode를 지정한다. */
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
- // Android EditText는 자체 canvas 경계 안에서 text와 scroll offset을 먼저 자른다.
799
- // autoresize 시 세로 padding을 wrapper가 아니라 native가 소유해야 첫 줄 여유가 유효하다.
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 ? classes.textareaNativeAutoresize : classes.textareaFixed,
808
- autoresize && isAndroid && classes.textareaAndroidAutoresize,
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={control.readOnly ? false : showSoftInputOnFocus}
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
- maxlength={resolvedMaxLength}
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 (!autoresize) return textarea;
870
+ if (!usesIOSAutoresizeWrapper) return textarea;
830
871
 
831
872
  return (
832
873
  <view
833
- className={clsx(classes.textareaRoot, !isAndroid && classes.textareaAutoresizeRoot)}
874
+ ignore-focus={true}
875
+ className={clsx(classes.textareaRoot, classes.textareaAutoresizeRoot)}
834
876
  bindtap={handleTextareaRootTap}
835
877
  bindlayoutchange={control.notifyLayoutChanged}
836
878
  >