@seed-design/lynx-react 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/lib/components/Field/Field.d.ts +47 -0
  2. package/lib/components/Field/Field.d.ts.map +1 -0
  3. package/lib/components/Field/Field.jsx +121 -0
  4. package/lib/components/Field/Field.namespace.d.ts +2 -0
  5. package/lib/components/Field/Field.namespace.d.ts.map +1 -0
  6. package/lib/components/Field/Field.namespace.js +16 -0
  7. package/lib/components/Field/context.d.ts +19 -0
  8. package/lib/components/Field/context.d.ts.map +1 -0
  9. package/lib/components/Field/context.js +10 -0
  10. package/lib/components/Field/index.d.ts +3 -0
  11. package/lib/components/Field/index.d.ts.map +1 -0
  12. package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.d.ts +42 -0
  13. package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.d.ts.map +1 -0
  14. package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.jsx +140 -0
  15. package/lib/components/KeyboardAvoidingScrollView/context.d.ts +17 -0
  16. package/lib/components/KeyboardAvoidingScrollView/context.d.ts.map +1 -0
  17. package/lib/components/KeyboardAvoidingScrollView/context.js +12 -0
  18. package/lib/components/KeyboardAvoidingScrollView/engine.d.ts +50 -0
  19. package/lib/components/KeyboardAvoidingScrollView/engine.d.ts.map +1 -0
  20. package/lib/components/KeyboardAvoidingScrollView/engine.js +259 -0
  21. package/lib/components/KeyboardAvoidingScrollView/geometry.d.ts +50 -0
  22. package/lib/components/KeyboardAvoidingScrollView/geometry.d.ts.map +1 -0
  23. package/lib/components/KeyboardAvoidingScrollView/geometry.js +90 -0
  24. package/lib/components/KeyboardAvoidingScrollView/index.d.ts +2 -0
  25. package/lib/components/KeyboardAvoidingScrollView/index.d.ts.map +1 -0
  26. package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.d.ts +11 -0
  27. package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.d.ts.map +1 -0
  28. package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.js +43 -0
  29. package/lib/components/KeyboardAvoidingScrollView/native-driver.d.ts +34 -0
  30. package/lib/components/KeyboardAvoidingScrollView/native-driver.d.ts.map +1 -0
  31. package/lib/components/KeyboardAvoidingScrollView/native-driver.js +133 -0
  32. package/lib/components/ProgressCircle/ProgressCircle.d.ts.map +1 -1
  33. package/lib/components/ProgressCircle/ProgressCircle.jsx +36 -5
  34. package/lib/components/Stack/Stack.d.ts.map +1 -1
  35. package/lib/components/Stack/Stack.jsx +10 -1
  36. package/lib/components/TagGroup/TagGroup.d.ts +2 -1
  37. package/lib/components/TagGroup/TagGroup.d.ts.map +1 -1
  38. package/lib/components/TagGroup/TagGroup.jsx +2 -1
  39. package/lib/components/TextField/TextField.d.ts +131 -0
  40. package/lib/components/TextField/TextField.d.ts.map +1 -0
  41. package/lib/components/TextField/TextField.jsx +434 -0
  42. package/lib/components/TextField/TextField.namespace.d.ts +2 -0
  43. package/lib/components/TextField/TextField.namespace.d.ts.map +1 -0
  44. package/lib/components/TextField/TextField.namespace.js +14 -0
  45. package/lib/components/TextField/context.d.ts +22 -0
  46. package/lib/components/TextField/context.d.ts.map +1 -0
  47. package/lib/components/TextField/context.js +7 -0
  48. package/lib/components/TextField/index.d.ts +4 -0
  49. package/lib/components/TextField/index.d.ts.map +1 -0
  50. package/lib/components/TextField/useTextFieldWithGraphemes.d.ts +24 -0
  51. package/lib/components/TextField/useTextFieldWithGraphemes.d.ts.map +1 -0
  52. package/lib/components/TextField/useTextFieldWithGraphemes.js +47 -0
  53. package/lib/components/index.d.ts +3 -0
  54. package/lib/components/index.d.ts.map +1 -1
  55. package/lib/index.js +7 -1
  56. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_grapheme_data.js +8 -0
  57. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_incb_data.js +14 -0
  58. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/core.js +68 -0
  59. package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/grapheme.js +175 -0
  60. package/package.json +7 -5
  61. package/src/components/Field/Field.namespace.ts +19 -0
  62. package/src/components/Field/Field.test.tsx +81 -0
  63. package/src/components/Field/Field.tsx +252 -0
  64. package/src/components/Field/context.ts +26 -0
  65. package/src/components/Field/index.ts +21 -0
  66. package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.test.tsx +267 -0
  67. package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.tsx +299 -0
  68. package/src/components/KeyboardAvoidingScrollView/context.ts +24 -0
  69. package/src/components/KeyboardAvoidingScrollView/engine.test.ts +451 -0
  70. package/src/components/KeyboardAvoidingScrollView/engine.ts +417 -0
  71. package/src/components/KeyboardAvoidingScrollView/geometry.test.ts +315 -0
  72. package/src/components/KeyboardAvoidingScrollView/geometry.ts +194 -0
  73. package/src/components/KeyboardAvoidingScrollView/index.ts +5 -0
  74. package/src/components/KeyboardAvoidingScrollView/keyboard-event-source.test.ts +122 -0
  75. package/src/components/KeyboardAvoidingScrollView/keyboard-event-source.ts +75 -0
  76. package/src/components/KeyboardAvoidingScrollView/native-driver.test.ts +223 -0
  77. package/src/components/KeyboardAvoidingScrollView/native-driver.ts +248 -0
  78. package/src/components/ProgressCircle/ProgressCircle.test.tsx +58 -0
  79. package/src/components/ProgressCircle/ProgressCircle.tsx +42 -5
  80. package/src/components/Stack/Stack.test.tsx +67 -0
  81. package/src/components/Stack/Stack.tsx +15 -1
  82. package/src/components/TagGroup/TagGroup.test.tsx +73 -0
  83. package/src/components/TagGroup/TagGroup.tsx +4 -2
  84. package/src/components/TextField/TextField.namespace.ts +16 -0
  85. package/src/components/TextField/TextField.test.tsx +1052 -0
  86. package/src/components/TextField/TextField.tsx +954 -0
  87. package/src/components/TextField/context.ts +33 -0
  88. package/src/components/TextField/index.ts +23 -0
  89. package/src/components/TextField/useTextFieldWithGraphemes.test.tsx +80 -0
  90. package/src/components/TextField/useTextFieldWithGraphemes.ts +78 -0
  91. package/src/components/index.ts +3 -0
@@ -0,0 +1,14 @@
1
+ import { decodeUnicodeData } from "./core.js";
2
+ //#region ../../node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_incb_data.js
3
+ /**
4
+ * @typedef {import('./core.js').UnicodeRange} UnicodeRange
5
+ * @typedef {import('./core.js').UnicodeDataEncoding} UnicodeDataEncoding
6
+ */
7
+ /**
8
+ * The Unicode `Indic_Conjunct_Break=Consonant` derived property table
9
+ *
10
+ * @type {UnicodeRange[]}
11
+ */
12
+ var consonant_ranges = decodeUnicodeData("1sl,10,1ug,7,1vc,7,1w5,j,1wq,6,1wy,,1x2,3,1y4,1,1y7,,1yo,1,239,j,23u,6,242,1,245,4,261,,26t,j,27e,6,27m,1,27p,4,28s,1,28v,,29d,,2dx,j,2ei,f,2fs,2,2l1,11");
13
+ //#endregion
14
+ export { consonant_ranges };
@@ -0,0 +1,68 @@
1
+ //#region ../../node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/core.js
2
+ /**
3
+ * @template {number} [T=number]
4
+ * @typedef {[from: number, to: number, category: T]} CategorizedUnicodeRange
5
+ */
6
+ /**
7
+ * @typedef {CategorizedUnicodeRange<0>} UnicodeRange
8
+ */
9
+ /**
10
+ * @typedef {string & { __tag: 'UnicodeDataEncoding' }} UnicodeDataEncoding
11
+ *
12
+ * Encoding for array of {@link UnicodeRange}, items separated by comma.
13
+ *
14
+ * Each {@link UnicodeDataRow} packed as a base36 integer:
15
+ *
16
+ * padding = to - from
17
+ * encoding = base36(from) + ',' + base36(padding)
18
+ *
19
+ * Notes:
20
+ * - base36 can hold surprisingly large numbers in a few characters.
21
+ * - The biggest codepoint is 0xE01F0 (918,000) at this point
22
+ * - The max value of a category is 23; https://www.unicode.org/reports/tr29/tr29-45.html#Table_Word_Break_Property_Values
23
+ * - The longest range is 42,720; CJK UNIFIED IDEOGRAPH-20000..CJK UNIFIED IDEOGRAPH-2A6DF
24
+ */
25
+ /**
26
+ * @template {number} [T=number]
27
+ * @param {UnicodeDataEncoding} data
28
+ * @param {string} [cats='']
29
+ * @returns {Array<CategorizedUnicodeRange<T>>}
30
+ */
31
+ function decodeUnicodeData(data, cats = "") {
32
+ let buf = [], nums = data.split(",").map((s) => s ? parseInt(s, 36) : 0), n = 0;
33
+ for (let i = 0; i < nums.length; i++) i % 2 ? buf.push([
34
+ n,
35
+ n + nums[i],
36
+ cats ? parseInt(cats[i >> 1], 36) : 0
37
+ ]) : n = nums[i];
38
+ return buf;
39
+ }
40
+ /**
41
+ * @template {object} Ext
42
+ * @typedef {{
43
+ * segment: string,
44
+ * index: number,
45
+ * input: string,
46
+ * } & Ext} SegmentOutput
47
+ */
48
+ /**
49
+ * @template {object} T
50
+ * @typedef {IterableIterator<SegmentOutput<T>>} Segmenter
51
+ */
52
+ /**
53
+ * @template {number} [T=number]
54
+ * @param {number} cp
55
+ * @param {CategorizedUnicodeRange<T>[]} ranges
56
+ * @return {number} index of matched unicode range, or -1 if no match
57
+ */
58
+ function findUnicodeRangeIndex(cp, ranges, lo = 0, hi = ranges.length - 1) {
59
+ while (lo <= hi) {
60
+ let mid = lo + hi >>> 1, range = ranges[mid];
61
+ if (cp < range[0]) hi = mid - 1;
62
+ else if (cp > range[1]) lo = mid + 1;
63
+ else return mid;
64
+ }
65
+ return -1;
66
+ }
67
+ //#endregion
68
+ export { decodeUnicodeData, findUnicodeRangeIndex };
@@ -0,0 +1,175 @@
1
+ import { findUnicodeRangeIndex } from "./core.js";
2
+ import { grapheme_ranges } from "./_grapheme_data.js";
3
+ import { consonant_ranges } from "./_incb_data.js";
4
+ //#region ../../node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/grapheme.js
5
+ var BMP_MAX = 65535;
6
+ /**
7
+ * Unicode segmentation by extended grapheme rules.
8
+ *
9
+ * This is fully compatible with the {@link Intl.Segmenter.segment} API
10
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter/segment
11
+ *
12
+ * @param {string} input
13
+ * @return {GraphemeSegmenter} iterator for grapheme cluster segments
14
+ */
15
+ function* graphemeSegments(input) {
16
+ let cp = input.codePointAt(0);
17
+ if (cp == null) return;
18
+ /** Current cursor position. */
19
+ let cursor = cp <= BMP_MAX ? 1 : 2;
20
+ /** Total length of the input string. */
21
+ let len = input.length;
22
+ /** Category of codepoint immediately preceding cursor */
23
+ let catBefore = cat(cp);
24
+ /** @type {GraphemeCategoryNum} Category of codepoint immediately preceding cursor. */
25
+ let catAfter = 0;
26
+ /** The number of RIS codepoints preceding `cursor`. */
27
+ let risCount = 0;
28
+ /**
29
+ * Emoji state for GB11: tracks if we've seen Extended_Pictographic followed by Extend* ZWJ
30
+ * Only relevant when catBefore === ZWJ && catAfter === Extended_Pictographic
31
+ */
32
+ let emoji = false;
33
+ /** InCB=Consonant - segment started with Indic consonant */
34
+ let consonant = false;
35
+ /** InCB=Linker - seen a linker after consonant */
36
+ let linker = false;
37
+ let index = 0;
38
+ /** Beginning category of a segment */
39
+ let _catBegin = catBefore;
40
+ /** Memoize the beginning code point of the segment. */
41
+ let _hd = cp;
42
+ while (cursor < len) {
43
+ cp = input.codePointAt(cursor);
44
+ catAfter = cat(cp);
45
+ let boundary = true;
46
+ if (catBefore === 1) boundary = catAfter !== 6;
47
+ else if (catBefore === 2 || catBefore === 6) boundary = true;
48
+ else if (catAfter === 1 || catAfter === 2 || catAfter === 6) boundary = true;
49
+ else if (catAfter === 3 || catAfter === 14 || catAfter === 11) boundary = false;
50
+ else if (catBefore === 9) boundary = false;
51
+ else if (catBefore === 14 && catAfter === 4) boundary = !emoji;
52
+ else if (catBefore === 10 && catAfter === 10) boundary = risCount++ % 2 === 1;
53
+ else if (catBefore === 5) boundary = !(catAfter === 5 || catAfter === 13 || catAfter === 7 || catAfter === 8);
54
+ else if ((catBefore === 7 || catBefore === 13) && (catAfter === 13 || catAfter === 12)) boundary = false;
55
+ else if ((catBefore === 8 || catBefore === 12) && catAfter === 12) boundary = false;
56
+ else if (catAfter === 0 && consonant && linker && isIndicConjunctConsonant(cp)) boundary = false;
57
+ if (boundary) {
58
+ yield {
59
+ segment: input.slice(index, cursor),
60
+ index,
61
+ input,
62
+ _hd,
63
+ _catBegin,
64
+ _catEnd: catBefore
65
+ };
66
+ emoji = false;
67
+ risCount = 0;
68
+ index = cursor;
69
+ _catBegin = catAfter;
70
+ _hd = cp;
71
+ } else if (catAfter === 14 && (catBefore === 3 || catBefore === 4)) emoji = true;
72
+ else if (cp >= 2325) {
73
+ if (!consonant && catBefore === 0) consonant = isIndicConjunctConsonant(_hd);
74
+ if (consonant && catAfter === 3) linker = linker || cp === 2381 || cp === 2509 || cp === 2637 || cp === 2765 || cp === 2893 || cp === 3149 || cp === 3405;
75
+ else linker = false;
76
+ }
77
+ cursor += cp <= BMP_MAX ? 1 : 2;
78
+ catBefore = catAfter;
79
+ }
80
+ if (index < len) yield {
81
+ segment: input.slice(index),
82
+ index,
83
+ input,
84
+ _hd,
85
+ _catBegin,
86
+ _catEnd: catBefore
87
+ };
88
+ }
89
+ /**
90
+ * Split given text into extended grapheme clusters.
91
+ *
92
+ * @param {string} text
93
+ * @return {IterableIterator<string>} iterator for grapheme clusters
94
+ *
95
+ * @see {@link graphemeSegments} if you need extra information.
96
+ *
97
+ * @example
98
+ * [...splitGraphemes('abc')] // => ['a', 'b', 'c']
99
+ */
100
+ function* splitGraphemes(text) {
101
+ for (let s of graphemeSegments(text)) yield s.segment;
102
+ }
103
+ var SEG0 = new Uint8Array(6080), SEG0_MIN = 128, SEG0_MAX = 12287;
104
+ var SEG1 = new Uint8Array(1536), SEG1_MIN = 40960, SEG1_MAX = 44031;
105
+ var SEG_CURSOR = (() => {
106
+ let cursor = 0;
107
+ while (true) {
108
+ let [start, end, cat] = grapheme_ranges[cursor];
109
+ if (start > SEG1_MAX) break;
110
+ cursor++;
111
+ if (end < SEG0_MIN || start > SEG0_MAX && end < SEG1_MIN) continue;
112
+ for (let cp = start; cp <= end; cp++) {
113
+ let seg, idx = 0;
114
+ if (cp <= SEG0_MAX) {
115
+ seg = SEG0;
116
+ idx = cp - SEG0_MIN >> 1;
117
+ } else {
118
+ seg = SEG1;
119
+ idx = cp - SEG1_MIN >> 1;
120
+ }
121
+ seg[idx] = cp & 1 ? seg[idx] & 15 | cat << 4 : seg[idx] & 240 | cat;
122
+ }
123
+ }
124
+ return cursor;
125
+ })();
126
+ /**
127
+ * `Grapheme_Cluster_Break` property value of a given codepoint
128
+ *
129
+ * @see https://www.unicode.org/reports/tr29/tr29-43.html#Default_Grapheme_Cluster_Table
130
+ *
131
+ * @param {number} cp
132
+ * @return {GraphemeCategoryNum}
133
+ */
134
+ function cat(cp) {
135
+ if (cp < SEG0_MIN) {
136
+ if (cp >= 32) return 0;
137
+ if (cp === 10) return 6;
138
+ if (cp === 13) return 1;
139
+ return 2;
140
+ }
141
+ if (cp <= SEG0_MAX) {
142
+ let byte = SEG0[cp - SEG0_MIN >> 1];
143
+ return cp & 1 ? byte >> 4 : byte & 15;
144
+ }
145
+ if (cp < SEG1_MIN) {
146
+ if (cp < 12336) return cp >= 12330 ? 3 : 0;
147
+ if (cp < 12443) {
148
+ if (cp === 12336 || cp === 12349) return 4;
149
+ return cp >= 12441 ? 3 : 0;
150
+ }
151
+ if (cp === 12951 || cp === 12953) return 4;
152
+ return 0;
153
+ }
154
+ if (cp <= SEG1_MAX) {
155
+ let byte = SEG1[cp - SEG1_MIN >> 1];
156
+ return cp & 1 ? byte >> 4 : byte & 15;
157
+ }
158
+ if (cp <= 55203) return (cp - 44032) % 28 === 0 ? 7 : 8;
159
+ if (cp <= 55295) {
160
+ if (cp <= 55238) return cp >= 55216 ? 13 : 0;
161
+ return cp >= 55243 ? 12 : 0;
162
+ }
163
+ if (cp < 65024) return cp === 64286 ? 3 : 0;
164
+ let idx = findUnicodeRangeIndex(cp, grapheme_ranges, SEG_CURSOR);
165
+ return idx < 0 ? 0 : grapheme_ranges[idx][2];
166
+ }
167
+ /**
168
+ * @param {number} cp
169
+ * @return {boolean}
170
+ */
171
+ function isIndicConjunctConsonant(cp) {
172
+ return findUnicodeRangeIndex(cp, consonant_ranges) >= 0;
173
+ }
174
+ //#endregion
175
+ export { splitGraphemes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-design/lynx-react",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/daangn/seed-design.git",
@@ -34,14 +34,15 @@
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.5.1",
42
+ "@seed-design/lynx-css": "0.8.1",
43
43
  "@testing-library/jest-dom": "^6.9.1",
44
44
  "jsdom": "^29.0.2",
45
+ "preact": "npm:@lynx-js/internal-preact@10.28.4-ee7bb26",
45
46
  "vite": "^8.0.0",
46
47
  "vite-plugin-dts": "^4.5.4",
47
48
  "vitest": "^3.2.4"
@@ -50,8 +51,9 @@
50
51
  "access": "public"
51
52
  },
52
53
  "dependencies": {
53
- "@lynx-js/lynx-ui-sheet": "^3.130.1",
54
54
  "@lynx-js/lynx-ui-common": "^3.130.0",
55
- "clsx": "^2.1.1"
55
+ "@lynx-js/lynx-ui-sheet": "^3.130.1",
56
+ "clsx": "^2.1.1",
57
+ "unicode-segmenter": "^0.14.4"
56
58
  }
57
59
  }
@@ -0,0 +1,19 @@
1
+ export {
2
+ FieldCharacterCount as CharacterCount,
3
+ FieldDescription as Description,
4
+ FieldErrorMessage as ErrorMessage,
5
+ FieldFooter as Footer,
6
+ FieldHeader as Header,
7
+ FieldIndicatorText as IndicatorText,
8
+ FieldLabel as Label,
9
+ FieldRequiredIndicator as RequiredIndicator,
10
+ FieldRoot as Root,
11
+ type FieldCharacterCountProps as CharacterCountProps,
12
+ type FieldDescriptionProps as DescriptionProps,
13
+ type FieldErrorMessageProps as ErrorMessageProps,
14
+ type FieldFooterProps as FooterProps,
15
+ type FieldHeaderProps as HeaderProps,
16
+ type FieldIndicatorTextProps as IndicatorTextProps,
17
+ type FieldLabelProps as LabelProps,
18
+ type FieldRootProps as RootProps,
19
+ } from "./Field";
@@ -0,0 +1,81 @@
1
+ import "@testing-library/jest-dom";
2
+ import { getQueriesForElement, render } from "@lynx-js/react/testing-library";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { Field } from "./index";
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
+ return root;
15
+ }
16
+
17
+ function getRenderedQueries() {
18
+ return getQueriesForElement(getRenderedRoot());
19
+ }
20
+
21
+ describe("Field", () => {
22
+ it("renders the complete field composition with recipe slot classes", () => {
23
+ render(
24
+ <Field.Root className="custom-field" required>
25
+ <Field.Header>
26
+ <Field.Label weight="bold">
27
+ 제목
28
+ <Field.RequiredIndicator />
29
+ <Field.IndicatorText>필수</Field.IndicatorText>
30
+ </Field.Label>
31
+ </Field.Header>
32
+ <Field.Footer>
33
+ <Field.Description>설명</Field.Description>
34
+ <Field.CharacterCount current={3} max={10} />
35
+ </Field.Footer>
36
+ </Field.Root>,
37
+ );
38
+
39
+ const root = getRenderedRoot();
40
+ const { getByText } = getRenderedQueries();
41
+ const fieldRoot = root.querySelector(".seed-field__root");
42
+
43
+ expect(fieldRoot).toHaveClass("custom-field");
44
+ expect(root.querySelector(".seed-field__header")).toBeInTheDocument();
45
+ expect(root.querySelector(".seed-field__footer")).toBeInTheDocument();
46
+ expect(getByText("제목")).toHaveClass("seed-field-label__root--weight_bold");
47
+ expect(getByText("필수")).toHaveClass("seed-field-label__indicatorText");
48
+ expect(getByText("필수").textContent).toBe("\u00a0필수");
49
+ expect(getByText("*")).toHaveAttribute("accessibility-elements-hidden", "true");
50
+ expect(getByText("*").textContent).toBe("\u200a*");
51
+ expect(getByText("설명")).toHaveClass("seed-field__description");
52
+ expect(root.querySelector(".seed-field__characterCount")).toHaveTextContent("3");
53
+ expect(root.querySelector(".seed-field__maxCharacterCount")).toHaveTextContent("/10");
54
+ });
55
+
56
+ it("applies invalid and empty character count variants", () => {
57
+ render(
58
+ <Field.Root invalid>
59
+ <Field.Footer>
60
+ <Field.ErrorMessage>오류</Field.ErrorMessage>
61
+ <Field.CharacterCount current={0} max={10} />
62
+ </Field.Footer>
63
+ </Field.Root>,
64
+ );
65
+
66
+ const { getByText } = getRenderedQueries();
67
+
68
+ expect(getByText("오류")).toHaveClass("seed-field__errorMessage");
69
+ const root = getRenderedRoot();
70
+ const current = root.querySelector(".seed-field__characterCount");
71
+ const max = root.querySelector(".seed-field__maxCharacterCount");
72
+
73
+ expect(current).toHaveClass("seed-field__characterCount--invalid_true");
74
+ expect(current).toHaveClass("seed-field__characterCount--empty_true");
75
+ expect(max).toHaveClass("seed-field__maxCharacterCount--invalid_true");
76
+ });
77
+
78
+ it("throws when a compound slot is rendered outside Field.Root", () => {
79
+ expect(() => render(<Field.Footer />)).toThrow(/ClassNamesProvider/);
80
+ });
81
+ });
@@ -0,0 +1,252 @@
1
+ import * as React from "@lynx-js/react";
2
+ import type { NodesRef } from "@lynx-js/types";
3
+ import { field, type FieldVariantProps } from "@seed-design/lynx-css/recipes/field";
4
+ import { fieldLabel, type FieldLabelVariantProps } from "@seed-design/lynx-css/recipes/field-label";
5
+ import clsx from "clsx";
6
+
7
+ import type { LynxStyledElementProps, LynxTextRef, LynxViewRef } from "../../types";
8
+ import { createSlotRecipeContext } from "../../utils/create-slot-recipe-context";
9
+ import { FieldContext, useFieldContext } from "./context";
10
+
11
+ const { ClassNamesProvider: FieldClassNamesProvider, useClassNames: useFieldClassNames } =
12
+ createSlotRecipeContext(field);
13
+ const { ClassNamesProvider: FieldLabelClassNamesProvider, useClassNames: useFieldLabelClassNames } =
14
+ createSlotRecipeContext(fieldLabel);
15
+
16
+ ////////////////////////////////////////////////////////////////////////////////////
17
+
18
+ /**
19
+ * @platform Lynx
20
+ *
21
+ * 웹 대비 미지원 기능:
22
+ * - HTML label의 `for` 연결과 native form의 `name` 제출 모델
23
+ * - DOM ARIA id 연결. 입력 컴포넌트에서 `accessibility-label`을 사용해야 함
24
+ */
25
+ export interface FieldRootProps extends Omit<FieldVariantProps, "empty">, LynxStyledElementProps {
26
+ required?: boolean;
27
+ disabled?: boolean;
28
+ readOnly?: boolean;
29
+ }
30
+
31
+ export const FieldRoot = React.forwardRef<NodesRef, FieldRootProps>((props, forwardedRef) => {
32
+ const [variantProps, otherProps] = field.splitVariantProps(props);
33
+ const {
34
+ children,
35
+ className,
36
+ required = false,
37
+ disabled = false,
38
+ readOnly = false,
39
+ ...nativeProps
40
+ } = otherProps;
41
+ const invalid = variantProps.invalid ?? false;
42
+ const [focused, setFocused] = React.useState(false);
43
+ const rootRef = React.useRef<NodesRef | null>(null);
44
+ const classes = field({ invalid });
45
+
46
+ const mergedRef = React.useCallback(
47
+ (node: NodesRef | null) => {
48
+ "background only";
49
+
50
+ rootRef.current = node;
51
+ if (typeof forwardedRef === "function") {
52
+ forwardedRef(node);
53
+ } else if (forwardedRef) {
54
+ forwardedRef.current = node;
55
+ }
56
+ },
57
+ [forwardedRef],
58
+ );
59
+
60
+ const contextValue = React.useMemo(
61
+ () => ({
62
+ rootRef,
63
+ disabled,
64
+ invalid,
65
+ readOnly,
66
+ required,
67
+ focused,
68
+ setFocused,
69
+ }),
70
+ [disabled, focused, invalid, readOnly, required],
71
+ );
72
+
73
+ return (
74
+ <FieldContext.Provider value={contextValue}>
75
+ <FieldClassNamesProvider value={classes}>
76
+ <view ref={mergedRef} className={clsx(classes.root, className)} {...nativeProps}>
77
+ {children}
78
+ </view>
79
+ </FieldClassNamesProvider>
80
+ </FieldContext.Provider>
81
+ );
82
+ });
83
+ FieldRoot.displayName = "FieldRoot";
84
+
85
+ ////////////////////////////////////////////////////////////////////////////////////
86
+
87
+ export interface FieldHeaderProps extends LynxStyledElementProps {}
88
+
89
+ export const FieldHeader = React.forwardRef<unknown, FieldHeaderProps>((props, ref) => {
90
+ const classes = useFieldClassNames();
91
+ const { children, className, ...nativeProps } = props;
92
+
93
+ return (
94
+ <view
95
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
96
+ className={clsx(classes.header, className)}
97
+ {...nativeProps}
98
+ >
99
+ {children}
100
+ </view>
101
+ );
102
+ });
103
+ FieldHeader.displayName = "FieldHeader";
104
+
105
+ export interface FieldLabelProps extends FieldLabelVariantProps, LynxStyledElementProps {}
106
+
107
+ export const FieldLabel = React.forwardRef<unknown, FieldLabelProps>((props, ref) => {
108
+ const [variantProps, otherProps] = fieldLabel.splitVariantProps(props);
109
+ const { children, className, ...nativeProps } = otherProps;
110
+ const classes = fieldLabel(variantProps);
111
+
112
+ return (
113
+ <FieldLabelClassNamesProvider value={classes}>
114
+ <text
115
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
116
+ className={clsx(classes.root, className)}
117
+ {...nativeProps}
118
+ >
119
+ {children}
120
+ </text>
121
+ </FieldLabelClassNamesProvider>
122
+ );
123
+ });
124
+ FieldLabel.displayName = "FieldLabel";
125
+
126
+ export interface FieldIndicatorTextProps extends LynxStyledElementProps {}
127
+
128
+ export const FieldIndicatorText = React.forwardRef<unknown, FieldIndicatorTextProps>(
129
+ (props, ref) => {
130
+ const classes = useFieldLabelClassNames();
131
+ const { children, className, ...nativeProps } = props;
132
+
133
+ return (
134
+ <text
135
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
136
+ className={clsx(classes.indicatorText, className)}
137
+ {...nativeProps}
138
+ >
139
+ {"\u00a0"}
140
+ {children}
141
+ </text>
142
+ );
143
+ },
144
+ );
145
+ FieldIndicatorText.displayName = "FieldIndicatorText";
146
+
147
+ export interface FieldRequiredIndicatorProps extends LynxStyledElementProps {}
148
+
149
+ export const FieldRequiredIndicator = React.forwardRef<unknown, FieldRequiredIndicatorProps>(
150
+ (props, ref) => {
151
+ const classes = useFieldLabelClassNames();
152
+ const { children = "*", className, ...nativeProps } = props;
153
+
154
+ return (
155
+ <text
156
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
157
+ accessibility-elements-hidden={true}
158
+ className={clsx(classes.indicatorIcon, className)}
159
+ {...nativeProps}
160
+ >
161
+ {"\u200a"}
162
+ {children}
163
+ </text>
164
+ );
165
+ },
166
+ );
167
+ FieldRequiredIndicator.displayName = "FieldRequiredIndicator";
168
+
169
+ ////////////////////////////////////////////////////////////////////////////////////
170
+
171
+ export interface FieldFooterProps extends LynxStyledElementProps {}
172
+
173
+ export const FieldFooter = React.forwardRef<unknown, FieldFooterProps>((props, ref) => {
174
+ const classes = useFieldClassNames();
175
+ const { children, className, ...nativeProps } = props;
176
+
177
+ return (
178
+ <view
179
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
180
+ className={clsx(classes.footer, className)}
181
+ {...nativeProps}
182
+ >
183
+ {children}
184
+ </view>
185
+ );
186
+ });
187
+ FieldFooter.displayName = "FieldFooter";
188
+
189
+ export interface FieldDescriptionProps extends LynxStyledElementProps {}
190
+
191
+ export const FieldDescription = React.forwardRef<unknown, FieldDescriptionProps>((props, ref) => {
192
+ const classes = useFieldClassNames();
193
+ const { children, className, ...nativeProps } = props;
194
+
195
+ return (
196
+ <text
197
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
198
+ className={clsx(classes.description, className)}
199
+ {...nativeProps}
200
+ >
201
+ {children}
202
+ </text>
203
+ );
204
+ });
205
+ FieldDescription.displayName = "FieldDescription";
206
+
207
+ export interface FieldErrorMessageProps extends LynxStyledElementProps {}
208
+
209
+ export const FieldErrorMessage = React.forwardRef<unknown, FieldErrorMessageProps>((props, ref) => {
210
+ const classes = useFieldClassNames();
211
+ const { children, className, ...nativeProps } = props;
212
+
213
+ return (
214
+ <text
215
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
216
+ className={clsx(classes.errorMessage, className)}
217
+ {...nativeProps}
218
+ >
219
+ {children}
220
+ </text>
221
+ );
222
+ });
223
+ FieldErrorMessage.displayName = "FieldErrorMessage";
224
+
225
+ ////////////////////////////////////////////////////////////////////////////////////
226
+
227
+ export interface FieldCharacterCountProps extends LynxStyledElementProps {
228
+ /** 현재 문자 또는 grapheme 수 */
229
+ current: number;
230
+ /** 허용하는 최대 문자 또는 grapheme 수 */
231
+ max: number;
232
+ }
233
+
234
+ export const FieldCharacterCount = React.forwardRef<unknown, FieldCharacterCountProps>(
235
+ (props, ref) => {
236
+ const { current, max, children: _children, className, ...nativeProps } = props;
237
+ const context = useFieldContext({ strict: true });
238
+ const classes = field({ invalid: context.invalid, empty: current === 0 });
239
+
240
+ return (
241
+ <view
242
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
243
+ className={clsx(classes.characterCountArea, className)}
244
+ {...nativeProps}
245
+ >
246
+ <text className={classes.characterCount}>{current}</text>
247
+ <text className={classes.maxCharacterCount}>/{max}</text>
248
+ </view>
249
+ );
250
+ },
251
+ );
252
+ FieldCharacterCount.displayName = "FieldCharacterCount";
@@ -0,0 +1,26 @@
1
+ import * as React from "@lynx-js/react";
2
+ import type { NodesRef } from "@lynx-js/types";
3
+
4
+ export interface FieldContextValue {
5
+ rootRef: React.RefObject<NodesRef | null>;
6
+ disabled: boolean;
7
+ invalid: boolean;
8
+ readOnly: boolean;
9
+ required: boolean;
10
+ focused: boolean;
11
+ setFocused: (focused: boolean) => void;
12
+ }
13
+
14
+ export const FieldContext = React.createContext<FieldContextValue | null>(null);
15
+
16
+ export function useFieldContext(options: { strict: true }): FieldContextValue;
17
+ export function useFieldContext(options?: { strict?: false }): FieldContextValue | null;
18
+ export function useFieldContext(options: { strict?: boolean } = {}): FieldContextValue | null {
19
+ const context = React.useContext(FieldContext);
20
+
21
+ if (options.strict && !context) {
22
+ throw new Error("Field compound components must be rendered inside <Field.Root>.");
23
+ }
24
+
25
+ return context;
26
+ }