@seed-design/react 2.2.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/lib/components/ActionButton/ActionButton.cjs +5 -2
  2. package/lib/components/ActionButton/ActionButton.d.ts.map +1 -1
  3. package/lib/components/ActionButton/ActionButton.js +5 -2
  4. package/lib/components/AttachmentDisplay/AttachmentDisplayItem.cjs +9 -6
  5. package/lib/components/AttachmentDisplay/AttachmentDisplayItem.d.ts.map +1 -1
  6. package/lib/components/AttachmentDisplay/AttachmentDisplayItem.js +9 -6
  7. package/lib/components/AttachmentDisplay/AttachmentDisplayTrigger.cjs +2 -1
  8. package/lib/components/AttachmentDisplay/AttachmentDisplayTrigger.d.ts.map +1 -1
  9. package/lib/components/AttachmentDisplay/AttachmentDisplayTrigger.js +2 -1
  10. package/lib/components/AttachmentInput/AttachmentInputItem.cjs +9 -6
  11. package/lib/components/AttachmentInput/AttachmentInputItem.d.ts.map +1 -1
  12. package/lib/components/AttachmentInput/AttachmentInputItem.js +9 -6
  13. package/lib/components/AttachmentInput/AttachmentInputTrigger.cjs +2 -1
  14. package/lib/components/AttachmentInput/AttachmentInputTrigger.d.ts.map +1 -1
  15. package/lib/components/AttachmentInput/AttachmentInputTrigger.js +2 -1
  16. package/lib/components/BottomSheet/BottomSheet.cjs +5 -3
  17. package/lib/components/BottomSheet/BottomSheet.d.ts.map +1 -1
  18. package/lib/components/BottomSheet/BottomSheet.js +5 -3
  19. package/lib/components/Callout/Callout.cjs +3 -2
  20. package/lib/components/Callout/Callout.d.ts.map +1 -1
  21. package/lib/components/Callout/Callout.js +3 -2
  22. package/lib/components/Checkbox/Checkbox.cjs +2 -1
  23. package/lib/components/Checkbox/Checkbox.d.ts.map +1 -1
  24. package/lib/components/Checkbox/Checkbox.js +2 -1
  25. package/lib/components/Chip/Chip.cjs +2 -1
  26. package/lib/components/Chip/Chip.d.ts.map +1 -1
  27. package/lib/components/Chip/Chip.js +2 -1
  28. package/lib/components/ChipTabs/ChipTabs.cjs +2 -1
  29. package/lib/components/ChipTabs/ChipTabs.d.ts.map +1 -1
  30. package/lib/components/ChipTabs/ChipTabs.js +2 -1
  31. package/lib/components/ContentDialog/ContentDialog.cjs +5 -3
  32. package/lib/components/ContentDialog/ContentDialog.d.ts.map +1 -1
  33. package/lib/components/ContentDialog/ContentDialog.js +5 -3
  34. package/lib/components/ContextualFloatingButton/ContextualFloatingButton.cjs +5 -2
  35. package/lib/components/ContextualFloatingButton/ContextualFloatingButton.d.ts.map +1 -1
  36. package/lib/components/ContextualFloatingButton/ContextualFloatingButton.js +5 -2
  37. package/lib/components/DatePicker/DatePicker.cjs +2 -2
  38. package/lib/components/DatePicker/DatePicker.d.ts +10 -4
  39. package/lib/components/DatePicker/DatePicker.d.ts.map +1 -1
  40. package/lib/components/DatePicker/DatePicker.js +2 -2
  41. package/lib/components/DatePicker/index.d.ts +1 -1
  42. package/lib/components/DatePicker/index.d.ts.map +1 -1
  43. package/lib/components/FieldButton/FieldButton.cjs +2 -1
  44. package/lib/components/FieldButton/FieldButton.d.ts.map +1 -1
  45. package/lib/components/FieldButton/FieldButton.js +2 -1
  46. package/lib/components/FloatingActionButton/FloatingActionButton.cjs +2 -1
  47. package/lib/components/FloatingActionButton/FloatingActionButton.d.ts.map +1 -1
  48. package/lib/components/FloatingActionButton/FloatingActionButton.js +2 -1
  49. package/lib/components/HelpBubble/HelpBubble.cjs +2 -1
  50. package/lib/components/HelpBubble/HelpBubble.d.ts.map +1 -1
  51. package/lib/components/HelpBubble/HelpBubble.js +2 -1
  52. package/lib/components/ImageFrame/ImageFrame.cjs +5 -2
  53. package/lib/components/ImageFrame/ImageFrame.d.ts.map +1 -1
  54. package/lib/components/ImageFrame/ImageFrame.js +5 -2
  55. package/lib/components/LoadingIndicator/usePendingButton.d.ts +57 -57
  56. package/lib/components/MenuSheet/MenuSheet.cjs +2 -1
  57. package/lib/components/MenuSheet/MenuSheet.d.ts.map +1 -1
  58. package/lib/components/MenuSheet/MenuSheet.js +2 -1
  59. package/lib/components/PageBanner/PageBanner.cjs +3 -2
  60. package/lib/components/PageBanner/PageBanner.d.ts.map +1 -1
  61. package/lib/components/PageBanner/PageBanner.js +3 -2
  62. package/lib/components/QuantityPicker/QuantityPicker.cjs +8 -4
  63. package/lib/components/QuantityPicker/QuantityPicker.d.ts.map +1 -1
  64. package/lib/components/QuantityPicker/QuantityPicker.js +8 -4
  65. package/lib/components/RadioGroup/RadioGroup.cjs +2 -1
  66. package/lib/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  67. package/lib/components/RadioGroup/RadioGroup.js +2 -1
  68. package/lib/components/ReactionButton/ReactionButton.cjs +5 -2
  69. package/lib/components/ReactionButton/ReactionButton.d.ts.map +1 -1
  70. package/lib/components/ReactionButton/ReactionButton.js +5 -2
  71. package/lib/components/ScrollFog/ScrollFog.d.ts +4 -6
  72. package/lib/components/ScrollFog/ScrollFog.d.ts.map +1 -1
  73. package/lib/components/ScrollFog/ScrollFog.test.d.ts +2 -0
  74. package/lib/components/ScrollFog/ScrollFog.test.d.ts.map +1 -0
  75. package/lib/components/SideNavigation/SideNavigation.cjs +1 -1
  76. package/lib/components/SideNavigation/SideNavigation.js +1 -1
  77. package/lib/components/SidePanel/SidePanel.cjs +5 -3
  78. package/lib/components/SidePanel/SidePanel.d.ts.map +1 -1
  79. package/lib/components/SidePanel/SidePanel.js +5 -3
  80. package/lib/components/Slider/Slider.cjs +1 -1
  81. package/lib/components/Slider/Slider.js +1 -1
  82. package/lib/components/Snackbar/Snackbar.cjs +2 -1
  83. package/lib/components/Snackbar/Snackbar.d.ts.map +1 -1
  84. package/lib/components/Snackbar/Snackbar.js +2 -1
  85. package/lib/components/SwipeableMenuSheet/SwipeableMenuSheet.cjs +2 -1
  86. package/lib/components/SwipeableMenuSheet/SwipeableMenuSheet.d.ts.map +1 -1
  87. package/lib/components/SwipeableMenuSheet/SwipeableMenuSheet.js +2 -1
  88. package/lib/components/Switch/Switch.cjs +2 -1
  89. package/lib/components/Switch/Switch.d.ts.map +1 -1
  90. package/lib/components/Switch/Switch.js +2 -1
  91. package/lib/components/Tabs/Tabs.cjs +2 -1
  92. package/lib/components/Tabs/Tabs.d.ts.map +1 -1
  93. package/lib/components/Tabs/Tabs.js +2 -1
  94. package/lib/components/TextField/TextField.cjs +1 -1
  95. package/lib/components/TextField/TextField.js +1 -1
  96. package/lib/components/TextField/useTextFieldWithGraphemes.cjs +2 -5
  97. package/lib/components/TextField/useTextFieldWithGraphemes.d.ts.map +1 -1
  98. package/lib/components/TextField/useTextFieldWithGraphemes.js +3 -6
  99. package/lib/components/ToggleButton/ToggleButton.cjs +5 -2
  100. package/lib/components/ToggleButton/ToggleButton.d.ts.map +1 -1
  101. package/lib/components/ToggleButton/ToggleButton.js +5 -2
  102. package/lib/components/WheelPicker/WheelPicker.cjs +94 -0
  103. package/lib/components/WheelPicker/WheelPicker.d.ts +56 -0
  104. package/lib/components/WheelPicker/WheelPicker.d.ts.map +1 -0
  105. package/lib/components/WheelPicker/WheelPicker.js +89 -0
  106. package/lib/components/WheelPicker/WheelPicker.namespace.cjs +15 -0
  107. package/lib/components/WheelPicker/WheelPicker.namespace.d.ts +2 -0
  108. package/lib/components/WheelPicker/WheelPicker.namespace.d.ts.map +1 -0
  109. package/lib/components/WheelPicker/WheelPicker.namespace.js +10 -0
  110. package/lib/components/WheelPicker/WheelPicker.test.d.ts +2 -0
  111. package/lib/components/WheelPicker/WheelPicker.test.d.ts.map +1 -0
  112. package/lib/components/WheelPicker/index.cjs +12 -0
  113. package/lib/components/WheelPicker/index.d.ts +3 -0
  114. package/lib/components/WheelPicker/index.d.ts.map +1 -0
  115. package/lib/components/WheelPicker/index.js +3 -0
  116. package/lib/components/index.cjs +11 -0
  117. package/lib/components/index.d.ts +1 -0
  118. package/lib/components/index.d.ts.map +1 -1
  119. package/lib/components/index.js +3 -1
  120. package/lib/components/private/WheelPicker.cjs +2 -3
  121. package/lib/components/private/WheelPicker.d.ts +3 -3
  122. package/lib/components/private/WheelPicker.d.ts.map +1 -1
  123. package/lib/components/private/WheelPicker.js +3 -3
  124. package/lib/components/private/useDismissible.d.ts +123 -123
  125. package/lib/index.cjs +14 -0
  126. package/lib/index.d.ts +2 -0
  127. package/lib/index.d.ts.map +1 -1
  128. package/lib/index.js +4 -1
  129. package/lib/utils/elementSizeVars.cjs +76 -0
  130. package/lib/utils/elementSizeVars.d.ts +15 -0
  131. package/lib/utils/elementSizeVars.d.ts.map +1 -0
  132. package/lib/utils/elementSizeVars.js +74 -0
  133. package/lib/utils/scaleFeedback.cjs +99 -0
  134. package/lib/utils/scaleFeedback.d.ts +58 -0
  135. package/lib/utils/scaleFeedback.d.ts.map +1 -0
  136. package/lib/utils/scaleFeedback.js +94 -0
  137. package/package.json +7 -7
  138. package/src/components/ActionButton/ActionButton.tsx +5 -2
  139. package/src/components/AttachmentDisplay/AttachmentDisplayItem.tsx +14 -9
  140. package/src/components/AttachmentDisplay/AttachmentDisplayTrigger.tsx +7 -4
  141. package/src/components/AttachmentInput/AttachmentInputItem.tsx +14 -9
  142. package/src/components/AttachmentInput/AttachmentInputTrigger.tsx +3 -3
  143. package/src/components/BottomSheet/BottomSheet.tsx +5 -3
  144. package/src/components/Callout/Callout.tsx +9 -4
  145. package/src/components/Checkbox/Checkbox.tsx +3 -3
  146. package/src/components/Chip/Chip.tsx +2 -1
  147. package/src/components/ChipTabs/ChipTabs.tsx +3 -3
  148. package/src/components/ContentDialog/ContentDialog.tsx +5 -3
  149. package/src/components/ContextualFloatingButton/ContextualFloatingButton.tsx +5 -2
  150. package/src/components/DatePicker/DatePicker.test.tsx +70 -0
  151. package/src/components/DatePicker/DatePicker.tsx +17 -16
  152. package/src/components/DatePicker/index.ts +2 -0
  153. package/src/components/FieldButton/FieldButton.tsx +6 -3
  154. package/src/components/FloatingActionButton/FloatingActionButton.tsx +4 -4
  155. package/src/components/HelpBubble/HelpBubble.tsx +7 -4
  156. package/src/components/HelpBubbleTooltip/HelpBubbleTooltip.tsx +1 -1
  157. package/src/components/ImageFrame/ImageFrame.tsx +8 -1
  158. package/src/components/MenuSheet/MenuSheet.tsx +6 -3
  159. package/src/components/PageBanner/PageBanner.tsx +5 -6
  160. package/src/components/QuantityPicker/QuantityPicker.tsx +8 -4
  161. package/src/components/RadioGroup/RadioGroup.tsx +7 -4
  162. package/src/components/ReactionButton/ReactionButton.tsx +5 -2
  163. package/src/components/ScrollFog/ScrollFog.test.tsx +13 -0
  164. package/src/components/ScrollFog/ScrollFog.tsx +5 -7
  165. package/src/components/SidePanel/SidePanel.tsx +5 -3
  166. package/src/components/Snackbar/Snackbar.tsx +6 -3
  167. package/src/components/SwipeableMenuSheet/SwipeableMenuSheet.tsx +7 -4
  168. package/src/components/Switch/Switch.tsx +3 -3
  169. package/src/components/Tabs/Tabs.tsx +3 -3
  170. package/src/components/TextField/useTextFieldWithGraphemes.ts +3 -7
  171. package/src/components/ToggleButton/ToggleButton.tsx +5 -2
  172. package/src/components/WheelPicker/WheelPicker.namespace.ts +9 -0
  173. package/src/components/WheelPicker/WheelPicker.test.tsx +270 -0
  174. package/src/components/WheelPicker/WheelPicker.tsx +210 -0
  175. package/src/components/WheelPicker/index.ts +11 -0
  176. package/src/components/index.ts +1 -0
  177. package/src/components/private/WheelPicker.test.tsx +1 -0
  178. package/src/components/private/WheelPicker.tsx +6 -6
  179. package/src/index.ts +2 -0
  180. package/src/utils/elementSizeVars.tsx +112 -0
  181. package/src/utils/scaleFeedback.tsx +128 -0
  182. package/lib/components/TextField/memoize.cjs +0 -13
  183. package/lib/components/TextField/memoize.d.ts +0 -2
  184. package/lib/components/TextField/memoize.d.ts.map +0 -1
  185. package/lib/components/TextField/memoize.js +0 -13
  186. package/src/components/TextField/memoize.ts +0 -14
@@ -0,0 +1,270 @@
1
+ import { fireEvent, render } from "@testing-library/react";
2
+ import { describe, expect, it, mock, spyOn } from "bun:test";
3
+ import type * as React from "react";
4
+ import { BottomSheet } from "../BottomSheet";
5
+ import { WheelPicker } from "./index";
6
+
7
+ const options = [
8
+ { value: "a", label: "A" },
9
+ { value: "b", label: "B" },
10
+ ];
11
+
12
+ function fireTouchPointer(type: string, element: HTMLElement, y: number) {
13
+ const event = new PointerEvent(type, {
14
+ bubbles: true,
15
+ cancelable: true,
16
+ pointerId: 1,
17
+ pointerType: "touch",
18
+ clientX: 50,
19
+ clientY: y,
20
+ });
21
+ Object.defineProperty(event, "pageX", { value: 50 });
22
+ Object.defineProperty(event, "pageY", { value: y });
23
+ fireEvent(element, event);
24
+ }
25
+
26
+ describe("WheelPicker", () => {
27
+ it("공개 기본 geometry와 중립 스타일을 적용한다", () => {
28
+ const { container, getByRole } = render(
29
+ <WheelPicker.Root aria-label="휠 피커">
30
+ <WheelPicker.Column aria-label="문자" options={options} defaultValue="a" />
31
+ </WheelPicker.Root>,
32
+ );
33
+
34
+ expect(getByRole("group")).toHaveStyle({
35
+ "--seed-wheel-picker-public-item-size": "44px",
36
+ "--seed-wheel-picker-public-visible-item-count": "5",
37
+ "--seed-wheel-picker-public-viewport-size": "220px",
38
+ "--seed-wheel-picker-public-center-offset": "88px",
39
+ "--seed-wheel-picker-public-scroll-fog-max-height": "132px",
40
+ });
41
+ expect(getByRole("group")).toHaveClass(
42
+ "seed-wheel-picker-public__root",
43
+ "seed-wheel-picker-public__root--size_medium",
44
+ );
45
+ expect(getByRole("group")).not.toHaveClass("seed-wheel-picker__root");
46
+ expect(container.querySelector("[data-wheel-picker-scroll-fog]")).toHaveClass(
47
+ "seed-wheel-picker-public__scrollFog",
48
+ );
49
+ expect(container.querySelector("[data-wheel-picker-columns]")).toHaveClass(
50
+ "seed-wheel-picker-public__columns",
51
+ );
52
+ expect(getByRole("spinbutton")).toHaveClass("seed-wheel-picker-public__column");
53
+ expect(container.querySelector('[data-wheel-picker-value="a"]')).toHaveClass(
54
+ "seed-wheel-picker-public__item",
55
+ );
56
+ expect(container.querySelector("[data-wheel-picker-item-label]")).toHaveClass(
57
+ "seed-wheel-picker-public__itemLabel",
58
+ );
59
+ expect(container.querySelector("[data-wheel-picker-indicator]")).toHaveClass(
60
+ "seed-wheel-picker-public__selectionIndicator",
61
+ );
62
+ const scrollFog = container.querySelector<HTMLElement>("[data-wheel-picker-scroll-fog]");
63
+ expect(scrollFog?.style.getPropertyValue("--scroll-fog-size-top")).toBe(
64
+ "var(--seed-wheel-picker-public-scroll-fog-size)",
65
+ );
66
+ expect(scrollFog?.style.getPropertyValue("--scroll-fog-size-bottom")).toBe(
67
+ "var(--seed-wheel-picker-public-scroll-fog-size)",
68
+ );
69
+ });
70
+
71
+ it("Small 크기의 geometry와 타이포그래피 Variant를 적용한다", () => {
72
+ const { container, getByRole } = render(
73
+ <WheelPicker.Root aria-label="작은 휠 피커" size="small">
74
+ <WheelPicker.Column aria-label="문자" options={options} defaultValue="a" />
75
+ </WheelPicker.Root>,
76
+ );
77
+
78
+ expect(getByRole("group")).toHaveStyle({
79
+ "--seed-wheel-picker-public-item-size": "36px",
80
+ "--seed-wheel-picker-public-visible-item-count": "5",
81
+ "--seed-wheel-picker-public-viewport-size": "180px",
82
+ "--seed-wheel-picker-public-center-offset": "72px",
83
+ "--seed-wheel-picker-public-scroll-fog-max-height": "108px",
84
+ });
85
+ expect(getByRole("group")).toHaveClass("seed-wheel-picker-public__root--size_small");
86
+ expect(container.querySelector("[data-wheel-picker-item-label]")).toHaveClass(
87
+ "seed-wheel-picker-public__itemLabel--size_small",
88
+ );
89
+ });
90
+
91
+ it("itemSize를 지정하면 Size의 Item 높이와 Scroll Fog 최대 높이만 덮어쓴다", () => {
92
+ const { container, getByRole } = render(
93
+ <WheelPicker.Root
94
+ aria-label="사용자 지정 휠 피커"
95
+ size="small"
96
+ itemSize={56}
97
+ visibleItemCount={7}
98
+ >
99
+ <WheelPicker.Column aria-label="문자" options={options} defaultValue="a" />
100
+ </WheelPicker.Root>,
101
+ );
102
+
103
+ expect(getByRole("group")).toHaveStyle({
104
+ "--seed-wheel-picker-public-item-size": "56px",
105
+ "--seed-wheel-picker-public-visible-item-count": "7",
106
+ "--seed-wheel-picker-public-viewport-size": "392px",
107
+ "--seed-wheel-picker-public-center-offset": "168px",
108
+ "--seed-wheel-picker-public-scroll-fog-max-height": "168px",
109
+ });
110
+ expect(container.querySelector("[data-wheel-picker-item-label]")).toHaveClass(
111
+ "seed-wheel-picker-public__itemLabel--size_small",
112
+ );
113
+ });
114
+
115
+ it("Item을 9개 표시해도 Scroll Fog 최대 높이는 Item 3개 높이로 유지한다", () => {
116
+ const { getByRole } = render(
117
+ <WheelPicker.Root aria-label="긴 휠 피커" visibleItemCount={9}>
118
+ <WheelPicker.Column aria-label="문자" options={options} defaultValue="a" />
119
+ </WheelPicker.Root>,
120
+ );
121
+
122
+ expect(getByRole("group")).toHaveStyle({
123
+ "--seed-wheel-picker-public-viewport-size": "396px",
124
+ "--seed-wheel-picker-public-scroll-fog-max-height": "132px",
125
+ });
126
+ });
127
+
128
+ it("Root와 Column의 className을 허용한다", () => {
129
+ const { getByRole } = render(
130
+ <WheelPicker.Root aria-label="휠 피커" className="custom-root">
131
+ <WheelPicker.Column aria-label="문자" options={options} className="custom-column" />
132
+ </WheelPicker.Root>,
133
+ );
134
+
135
+ expect(getByRole("group")).toHaveClass("custom-root");
136
+ expect(getByRole("spinbutton")).toHaveClass("custom-column");
137
+ });
138
+
139
+ it("renderLabel로 기본 ItemLabel을 대체한다", () => {
140
+ const { container, getByText } = render(
141
+ <WheelPicker.Root aria-label="휠 피커" itemSize={56}>
142
+ <WheelPicker.Column
143
+ aria-label="문자"
144
+ options={options}
145
+ renderLabel={(option) => <span data-custom-label="">{option.label}</span>}
146
+ />
147
+ </WheelPicker.Root>,
148
+ );
149
+
150
+ expect(getByText("A")).toHaveAttribute("data-custom-label");
151
+ expect(getByText("A").parentElement).toHaveClass("seed-wheel-picker-public__item");
152
+ expect(container.querySelector("[data-wheel-picker-item-label]")).not.toBeInTheDocument();
153
+ });
154
+
155
+ it("renderLabel에서 공개 ItemLabel을 재사용한다", () => {
156
+ const { getByText } = render(
157
+ <WheelPicker.Root aria-label="휠 피커">
158
+ <WheelPicker.Column
159
+ aria-label="문자"
160
+ options={options}
161
+ renderLabel={(option) => (
162
+ <WheelPicker.ItemLabel data-reused-label="">{option.label}</WheelPicker.ItemLabel>
163
+ )}
164
+ />
165
+ </WheelPicker.Root>,
166
+ );
167
+
168
+ expect(getByText("A")).toHaveClass("seed-wheel-picker-public__itemLabel");
169
+ expect(getByText("A")).toHaveAttribute("data-wheel-picker-item-label");
170
+ expect(getByText("A")).toHaveAttribute("data-reused-label");
171
+ });
172
+
173
+ it("option의 ariaLabel을 기본 접근성 텍스트로 사용한다", () => {
174
+ const { getByRole } = render(
175
+ <WheelPicker.Root aria-label="배지 선택">
176
+ <WheelPicker.Column
177
+ aria-label="배지"
178
+ options={[{ value: "popular", label: <span>◆ 인기</span>, ariaLabel: "인기" }]}
179
+ />
180
+ </WheelPicker.Root>,
181
+ );
182
+
183
+ expect(getByRole("spinbutton", { name: "배지" })).toHaveAttribute("aria-valuetext", "인기");
184
+ });
185
+
186
+ it("숫자 label을 기본 접근성 텍스트로 변환한다", () => {
187
+ const { getByRole } = render(
188
+ <WheelPicker.Root aria-label="수량 선택">
189
+ <WheelPicker.Column aria-label="수량" options={[{ value: "one", label: 1 }]} />
190
+ </WheelPicker.Root>,
191
+ );
192
+
193
+ expect(getByRole("spinbutton", { name: "수량" })).toHaveAttribute("aria-valuetext", "1");
194
+ });
195
+
196
+ it("Root의 disabled 상태를 Column과 Item에 전파한다", () => {
197
+ const { container, getByRole } = render(
198
+ <WheelPicker.Root aria-label="비활성 휠 피커" disabled>
199
+ <WheelPicker.Column aria-label="문자" options={options} defaultValue="a" />
200
+ </WheelPicker.Root>,
201
+ );
202
+
203
+ expect(getByRole("group")).toHaveAttribute("data-disabled");
204
+ expect(getByRole("spinbutton")).toHaveAttribute("data-disabled");
205
+ expect(getByRole("spinbutton")).toHaveAttribute("aria-disabled", "true");
206
+ expect(container.querySelector('[data-wheel-picker-value="a"]')).toHaveAttribute(
207
+ "data-selected",
208
+ );
209
+ });
210
+
211
+ it("지원하지 않는 readOnly 값이 런타임에 전달되어도 읽기 전용으로 동작하지 않는다", () => {
212
+ const RootWithLegacyReadOnly = WheelPicker.Root as React.ComponentType<
213
+ WheelPicker.RootProps & { readOnly?: boolean }
214
+ >;
215
+ const { getByRole } = render(
216
+ <RootWithLegacyReadOnly aria-label="휠 피커" readOnly>
217
+ <WheelPicker.Column aria-label="문자" options={options} defaultValue="a" />
218
+ </RootWithLegacyReadOnly>,
219
+ );
220
+
221
+ expect(getByRole("group")).not.toHaveAttribute("data-readonly");
222
+ expect(getByRole("spinbutton")).not.toHaveAttribute("aria-readonly");
223
+ });
224
+
225
+ it("BottomSheet 안에서 컬럼을 스크롤할 때 시트 드래그를 시작하지 않는다", () => {
226
+ const originalSetPointerCapture = HTMLElement.prototype.setPointerCapture;
227
+ HTMLElement.prototype.setPointerCapture = mock(() => {});
228
+
229
+ try {
230
+ const { container, getByRole } = render(
231
+ <BottomSheet.Root defaultOpen modal={false} autoFocus={false} skipAnimation>
232
+ <BottomSheet.Content>
233
+ <BottomSheet.Title>값 선택</BottomSheet.Title>
234
+ <BottomSheet.Description>원하는 값을 선택하세요.</BottomSheet.Description>
235
+ <WheelPicker.Root aria-label="휠 피커">
236
+ <WheelPicker.Column aria-label="문자" options={options} defaultValue="b" />
237
+ </WheelPicker.Root>
238
+ </BottomSheet.Content>
239
+ </BottomSheet.Root>,
240
+ );
241
+ const content = container.ownerDocument.querySelector("[data-drawer]") as HTMLElement;
242
+ const column = getByRole("spinbutton");
243
+
244
+ content.style.transform = "matrix(1, 0, 0, 1, 0, 0)";
245
+ spyOn(content, "getBoundingClientRect").mockReturnValue({
246
+ x: 0,
247
+ y: 0,
248
+ width: 320,
249
+ height: 400,
250
+ top: 0,
251
+ left: 0,
252
+ right: 320,
253
+ bottom: 400,
254
+ toJSON: () => {},
255
+ });
256
+ Object.defineProperties(column, {
257
+ clientHeight: { value: 220, configurable: true },
258
+ scrollHeight: { value: 440, configurable: true },
259
+ });
260
+ column.scrollTop = 44;
261
+
262
+ fireTouchPointer("pointerdown", column, 100);
263
+ fireTouchPointer("pointermove", column, 130);
264
+
265
+ expect(content.style.transform).toBe("matrix(1, 0, 0, 1, 0, 0)");
266
+ } finally {
267
+ HTMLElement.prototype.setPointerCapture = originalSetPointerCapture;
268
+ }
269
+ });
270
+ });
@@ -0,0 +1,210 @@
1
+ "use client";
2
+
3
+ import {
4
+ wheelPickerPublic,
5
+ type WheelPickerPublicVariantProps,
6
+ } from "@seed-design/css/recipes/wheel-picker-public";
7
+ import { Primitive, type PrimitiveProps } from "@seed-design/react-primitive";
8
+ import {
9
+ WheelPicker as WheelPickerPrimitive,
10
+ type WheelPickerOption,
11
+ } from "@seed-design/react-wheel-picker";
12
+ import clsx from "clsx";
13
+ import * as React from "react";
14
+ import { createSlotRecipeContext } from "../../utils/createSlotRecipeContext";
15
+ import { ScrollFog } from "../ScrollFog/ScrollFog";
16
+
17
+ const DEFAULT_ITEM_SIZE = {
18
+ small: 36,
19
+ medium: 44,
20
+ } as const;
21
+ const DEFAULT_VISIBLE_ITEM_COUNT = 5;
22
+ const { ClassNamesProvider, useClassNames } = createSlotRecipeContext(wheelPickerPublic);
23
+
24
+ type WheelPickerCssProperties = React.CSSProperties & {
25
+ "--seed-wheel-picker-public-item-size"?: string;
26
+ "--seed-wheel-picker-public-visible-item-count"?: number;
27
+ "--seed-wheel-picker-public-viewport-size"?: string;
28
+ "--seed-wheel-picker-public-center-offset"?: string;
29
+ "--seed-wheel-picker-public-scroll-fog-max-height"?: string;
30
+ };
31
+
32
+ export interface WheelPickerRootProps
33
+ extends WheelPickerPublicVariantProps,
34
+ Omit<
35
+ WheelPickerPrimitive.RootProps,
36
+ "asChild" | "children" | "disabled" | "itemSize" | "readOnly" | "visibleItemCount"
37
+ > {
38
+ /** Wheel Picker를 구성하는 `WheelPicker.Column` 목록입니다. */
39
+ children: React.ReactNode;
40
+
41
+ /** 모든 컬럼의 포커스와 값 변경을 막습니다. */
42
+ disabled?: boolean;
43
+
44
+ /**
45
+ * 한 항목의 높이입니다. 지정하지 않으면 `size`의 기본 높이를 사용합니다.
46
+ */
47
+ itemSize?: number;
48
+
49
+ /**
50
+ * 화면에 보이는 항목 수입니다. 5 이상의 홀수를 권장합니다.
51
+ * @default 5
52
+ */
53
+ visibleItemCount?: number;
54
+ }
55
+
56
+ export const WheelPickerRoot = React.forwardRef<HTMLDivElement, WheelPickerRootProps>(
57
+ (props, ref) => {
58
+ const [variantProps, otherProps] = wheelPickerPublic.splitVariantProps(props);
59
+ const {
60
+ children,
61
+ className,
62
+ itemSize: itemSizeProp,
63
+ style,
64
+ visibleItemCount = DEFAULT_VISIBLE_ITEM_COUNT,
65
+ ...rootProps
66
+ } = otherProps;
67
+ const size = variantProps.size ?? "medium";
68
+ const itemSize = itemSizeProp ?? DEFAULT_ITEM_SIZE[size];
69
+ const centerOffset = ((visibleItemCount - 1) / 2) * itemSize;
70
+ const classNames = wheelPickerPublic({ size });
71
+ const wheelPickerStyle: WheelPickerCssProperties = {
72
+ ...style,
73
+ "--seed-wheel-picker-public-item-size": `${itemSize}px`,
74
+ "--seed-wheel-picker-public-visible-item-count": visibleItemCount,
75
+ "--seed-wheel-picker-public-viewport-size": `${itemSize * visibleItemCount}px`,
76
+ "--seed-wheel-picker-public-center-offset": `${centerOffset}px`,
77
+ "--seed-wheel-picker-public-scroll-fog-max-height": `${itemSize * 3}px`,
78
+ };
79
+
80
+ return (
81
+ <ClassNamesProvider value={classNames}>
82
+ <WheelPickerPrimitive.Root
83
+ ref={ref}
84
+ className={clsx(classNames.root, className)}
85
+ itemSize={itemSize}
86
+ style={wheelPickerStyle}
87
+ visibleItemCount={visibleItemCount}
88
+ {...rootProps}
89
+ readOnly={false}
90
+ >
91
+ <Primitive.div
92
+ aria-hidden
93
+ className={classNames.selectionIndicator}
94
+ data-wheel-picker-indicator=""
95
+ />
96
+ <ScrollFog
97
+ className={classNames.scrollFog}
98
+ placement={["top", "bottom"]}
99
+ size="var(--seed-wheel-picker-public-scroll-fog-size)"
100
+ hideScrollBar
101
+ data-wheel-picker-scroll-fog=""
102
+ >
103
+ <Primitive.div className={classNames.columns} data-wheel-picker-columns="">
104
+ {children}
105
+ </Primitive.div>
106
+ </ScrollFog>
107
+ </WheelPickerPrimitive.Root>
108
+ </ClassNamesProvider>
109
+ );
110
+ },
111
+ );
112
+ WheelPickerRoot.displayName = "WheelPickerRoot";
113
+
114
+ export interface WheelPickerItemLabelProps
115
+ extends PrimitiveProps,
116
+ React.HTMLAttributes<HTMLDivElement> {}
117
+
118
+ /** Wheel Picker 항목의 기본 여백과 타이포그래피를 적용합니다. */
119
+ export const WheelPickerItemLabel = React.forwardRef<HTMLDivElement, WheelPickerItemLabelProps>(
120
+ ({ className, ...props }, ref) => {
121
+ const classNames = useClassNames();
122
+
123
+ return (
124
+ <Primitive.div
125
+ ref={ref}
126
+ className={clsx(classNames.itemLabel, className)}
127
+ {...props}
128
+ data-wheel-picker-item-label=""
129
+ />
130
+ );
131
+ },
132
+ );
133
+ WheelPickerItemLabel.displayName = "WheelPickerItemLabel";
134
+
135
+ export interface WheelPickerColumnProps
136
+ extends Omit<
137
+ WheelPickerPrimitive.ColumnProps,
138
+ | "asChild"
139
+ | "defaultValue"
140
+ | "getAriaValueText"
141
+ | "loop"
142
+ | "onIndexChange"
143
+ | "onValueChange"
144
+ | "options"
145
+ | "renderOption"
146
+ | "value"
147
+ | "valueChangeBehavior"
148
+ > {
149
+ /** 컬럼에 표시할 선택 항목입니다. 하나 이상의 항목을 제공해야 합니다. */
150
+ options: readonly WheelPickerOption[];
151
+
152
+ /** 제어 상태에서 현재 선택된 항목의 값입니다. */
153
+ value?: string;
154
+
155
+ /** 비제어 상태에서 처음 선택할 항목의 값입니다. */
156
+ defaultValue?: string;
157
+
158
+ /** 선택 값이 바뀔 때 호출됩니다. */
159
+ onValueChange?: WheelPickerPrimitive.ColumnProps["onValueChange"];
160
+
161
+ /** 사용자 조작으로 중앙 항목이 바뀔 때마다 호출됩니다. */
162
+ onIndexChange?: WheelPickerPrimitive.ColumnProps["onIndexChange"];
163
+
164
+ /**
165
+ * 마지막 항목과 첫 항목을 이어 반복해서 탐색할지 여부입니다.
166
+ * @default false
167
+ */
168
+ loop?: boolean;
169
+
170
+ /**
171
+ * 외부에서 `value`가 변경되었을 때 새 값으로 이동하는 스크롤 방식입니다.
172
+ * @default "auto"
173
+ */
174
+ valueChangeBehavior?: ScrollBehavior;
175
+
176
+ /** 현재 값에 대응하는 접근성 텍스트를 반환합니다. */
177
+ getAriaValueText?: (value: string) => string;
178
+
179
+ /**
180
+ * 기본 `ItemLabel` 대신 항목에 표시할 요소를 반환합니다.
181
+ * 반환한 요소는 항목의 선택·비활성 색상을 적용할 수 있도록 `currentColor`를 상속해야 합니다.
182
+ */
183
+ renderLabel?: (option: WheelPickerOption) => React.ReactNode;
184
+ }
185
+
186
+ export const WheelPickerColumn = React.forwardRef<HTMLDivElement, WheelPickerColumnProps>(
187
+ ({ className, renderLabel, ...props }, ref) => {
188
+ const classNames = useClassNames();
189
+
190
+ return (
191
+ <WheelPickerPrimitive.Column
192
+ ref={ref}
193
+ className={clsx(classNames.column, className)}
194
+ {...props}
195
+ renderOption={(option, optionProps) => (
196
+ <Primitive.div className={classNames.item} {...optionProps}>
197
+ {renderLabel ? (
198
+ renderLabel(option)
199
+ ) : (
200
+ <WheelPickerItemLabel>{option.label}</WheelPickerItemLabel>
201
+ )}
202
+ </Primitive.div>
203
+ )}
204
+ />
205
+ );
206
+ },
207
+ );
208
+ WheelPickerColumn.displayName = "WheelPickerColumn";
209
+
210
+ export type { WheelPickerOption };
@@ -0,0 +1,11 @@
1
+ export {
2
+ WheelPickerRoot,
3
+ WheelPickerColumn,
4
+ WheelPickerItemLabel,
5
+ type WheelPickerRootProps,
6
+ type WheelPickerColumnProps,
7
+ type WheelPickerItemLabelProps,
8
+ type WheelPickerOption,
9
+ } from "./WheelPicker";
10
+
11
+ export * as WheelPicker from "./WheelPicker.namespace";
@@ -84,3 +84,4 @@ export * from "./TextField";
84
84
  export * from "./TimePicker";
85
85
  export * from "./ToggleButton";
86
86
  export * from "./VisuallyHidden";
87
+ export * from "./WheelPicker";
@@ -49,6 +49,7 @@ describe("InternalWheelPicker", () => {
49
49
  expect(columns).toHaveClass("seed-wheel-picker__columns", "columns");
50
50
  expect(getAllByRole("spinbutton")).toHaveLength(2);
51
51
  expect(firstColumn).toHaveClass("seed-wheel-picker__column");
52
+ expect(firstColumn).not.toHaveClass("seed-wheel-picker-public__column");
52
53
  expect(firstColumn.firstElementChild).toHaveClass("seed-wheel-picker__item");
53
54
  });
54
55
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { wheelPicker } from "@seed-design/css/recipes/wheel-picker";
4
4
  import { Primitive } from "@seed-design/react-primitive";
5
- import * as HeadlessWheelPicker from "@seed-design/react-wheel-picker";
5
+ import { WheelPicker as WheelPickerPrimitive } from "@seed-design/react-wheel-picker";
6
6
  import clsx from "clsx";
7
7
  import * as React from "react";
8
8
  import { ScrollFog, type ScrollFogProps } from "../ScrollFog/ScrollFog";
@@ -17,7 +17,7 @@ type WheelPickerCssProperties = React.CSSProperties & {
17
17
  };
18
18
 
19
19
  export interface InternalWheelPickerRootProps
20
- extends Omit<HeadlessWheelPicker.WheelPickerRootProps, "asChild" | "children"> {
20
+ extends Omit<WheelPickerPrimitive.RootProps, "asChild" | "children"> {
21
21
  children: React.ReactNode;
22
22
  columnsClassName?: string;
23
23
  scrollFogClassName?: string;
@@ -54,7 +54,7 @@ export const InternalWheelPickerRoot = React.forwardRef<
54
54
  };
55
55
 
56
56
  return (
57
- <HeadlessWheelPicker.WheelPickerRoot
57
+ <WheelPickerPrimitive.Root
58
58
  ref={ref}
59
59
  itemSize={itemSize}
60
60
  visibleItemCount={visibleItemCount}
@@ -81,14 +81,14 @@ export const InternalWheelPickerRoot = React.forwardRef<
81
81
  {children}
82
82
  </Primitive.div>
83
83
  </ScrollFog>
84
- </HeadlessWheelPicker.WheelPickerRoot>
84
+ </WheelPickerPrimitive.Root>
85
85
  );
86
86
  },
87
87
  );
88
88
  InternalWheelPickerRoot.displayName = "InternalWheelPickerRoot";
89
89
 
90
90
  export interface InternalWheelPickerColumnProps
91
- extends Omit<HeadlessWheelPicker.WheelPickerColumnProps, "asChild" | "renderOption"> {
91
+ extends Omit<WheelPickerPrimitive.ColumnProps, "asChild" | "renderOption"> {
92
92
  itemClassName?: string;
93
93
  }
94
94
 
@@ -96,7 +96,7 @@ export const InternalWheelPickerColumn = React.forwardRef<
96
96
  HTMLDivElement,
97
97
  InternalWheelPickerColumnProps
98
98
  >(({ className, itemClassName, ...props }, ref) => (
99
- <HeadlessWheelPicker.WheelPickerColumn
99
+ <WheelPickerPrimitive.Column
100
100
  ref={ref}
101
101
  className={clsx(classNames.column, className)}
102
102
  renderOption={(option, optionProps) => (
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@ export type { ResponsiveValue, UnwrapResponsive, BreakpointThreshold } from "./t
5
5
  export { useBreakpoint } from "./hooks/useBreakpoint";
6
6
  export type { UseBreakpointOptions } from "./hooks/useBreakpoint";
7
7
  export { useBreakpointValue } from "./hooks/useBreakpointValue";
8
+ export { useScaleFeedback, ScaleFeedback } from "./utils/scaleFeedback";
9
+ export type { ScaleFeedbackProps } from "./utils/scaleFeedback";
8
10
  export { BreakpointProvider } from "./providers/BreakpointProvider";
9
11
  export type { BreakpointProviderProps } from "./providers/BreakpointProvider";
10
12
 
@@ -0,0 +1,112 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * Publishes an element's rendered size as CSS custom properties.
5
+ *
6
+ * The size is a plain fact about the element rather than a feature, so this
7
+ * util keeps unitless `--seed-element-width` / `--seed-element-height` in sync
8
+ * on the element and stops there. Whatever needs the size derives its own
9
+ * values from those vars in CSS.
10
+ *
11
+ * The names must stay in sync with `@seed-design/qvism-preset`
12
+ * (utils/scale-feedback.ts), which owns the formula that consumes them. Press
13
+ * detection stays in CSS (`:active` gates), so nothing here tracks interaction;
14
+ * without JS the vars are simply never set and consumers fall back to scale 1.
15
+ */
16
+
17
+ // One observer for every measured element; created lazily so the module stays
18
+ // importable during SSR.
19
+ let sharedObserver: ResizeObserver | null = null;
20
+
21
+ function getSharedObserver() {
22
+ sharedObserver ??= new ResizeObserver((entries) => {
23
+ // `borderBoxSize` is absent before Safari 15.4, inside the range SEED
24
+ // supports, so the `offsetWidth` / `offsetHeight` fallback is a live path
25
+ // rather than a formality. Reading every entry before writing any property
26
+ // is what keeps it free: layout is already clean when observations are
27
+ // broadcast, so the reads force nothing, while a write interleaved between
28
+ // them would dirty layout and make every later read reflow.
29
+ const measurements = entries.map((entry) => {
30
+ const element = entry.target as HTMLElement;
31
+ const size = entry.borderBoxSize?.[0];
32
+
33
+ return {
34
+ element,
35
+ width: size ? size.inlineSize : element.offsetWidth,
36
+ height: size ? size.blockSize : element.offsetHeight,
37
+ };
38
+ });
39
+
40
+ for (const { element, width, height } of measurements) {
41
+ element.style.setProperty("--seed-element-width", String(width));
42
+ element.style.setProperty("--seed-element-height", String(height));
43
+ }
44
+ });
45
+
46
+ return sharedObserver;
47
+ }
48
+
49
+ /**
50
+ * Returns a callback ref that keeps the size vars in sync on the element it is
51
+ * attached to. Compose it with the element's other refs however you like: being
52
+ * handed an element it already watches is a no-op, so the observation survives a
53
+ * ref whose identity changes every render and the vars are republished on resize
54
+ * rather than on render.
55
+ *
56
+ * Teardown still runs when the ref genuinely leaves an element — it is unobserved
57
+ * and its vars removed — so an element that keeps the class but loses the ref
58
+ * falls back to a ratio of 1 instead of holding a stale one.
59
+ */
60
+ export function useElementSizeVars() {
61
+ // What React last handed the ref, against what the observer is watching. The
62
+ // two disagree for exactly one commit whenever a ref identity changes, because
63
+ // React detaches with `null` before re-attaching the very same element.
64
+ const attachedRef = React.useRef<HTMLElement | null>(null);
65
+ const observedRef = React.useRef<HTMLElement | null>(null);
66
+
67
+ const release = React.useCallback(() => {
68
+ const observed = observedRef.current;
69
+ if (!observed) return;
70
+
71
+ observedRef.current = null;
72
+ getSharedObserver().unobserve(observed);
73
+ observed.style.removeProperty("--seed-element-width");
74
+ observed.style.removeProperty("--seed-element-height");
75
+ }, []);
76
+
77
+ const sizeVarsRef = React.useCallback(
78
+ (node: HTMLElement | null) => {
79
+ attachedRef.current = node;
80
+
81
+ if (!node) {
82
+ // A commit runs to completion synchronously, so this cannot land before
83
+ // the re-attach that an identity change performs within the same commit.
84
+ // By the time it runs, the two refs tell a detached element apart from
85
+ // one that never actually left.
86
+ queueMicrotask(() => {
87
+ if (attachedRef.current === observedRef.current) return;
88
+
89
+ release();
90
+ });
91
+ return;
92
+ }
93
+
94
+ if (observedRef.current === node) return;
95
+
96
+ release();
97
+ observedRef.current = node;
98
+ // `box` selects which box change marks the element active — it does not
99
+ // decide which sizes the entry carries (all of them are always present).
100
+ // Observing the content box would miss a border-box change that leaves the
101
+ // content box untouched, e.g. a variant that only alters padding.
102
+ //
103
+ // Before Safari 15.4 `observe` declares no options at all, so this argument
104
+ // is dropped and that miss is exactly what happens there. Bounded: the ratio
105
+ // stays valid, only derived from the last size whose content box moved.
106
+ getSharedObserver().observe(node, { box: "border-box" });
107
+ },
108
+ [release],
109
+ );
110
+
111
+ return { sizeVarsRef };
112
+ }