@ippon-ui/ui 0.0.2

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 (246) hide show
  1. package/.agents/agents/component-library-create-from-pattern-library.agent.md +37 -0
  2. package/.agents/agents/patten-library-create-component.agent.md +30 -0
  3. package/.agents/skills/component-library/SKILL.md +169 -0
  4. package/.agents/skills/pattern-library/SKILL.md +277 -0
  5. package/.github/workflows/build.yml +34 -0
  6. package/.gitlab-ci.yml +12 -0
  7. package/.prettierignore +6 -0
  8. package/AGENTS.md +50 -0
  9. package/LICENSE +202 -0
  10. package/ci/build.yml +15 -0
  11. package/ci/common.yml +42 -0
  12. package/ci/deploy.yml +20 -0
  13. package/icons/LICENCE +202 -0
  14. package/icons/index.ts +69 -0
  15. package/icons/package.json +25 -0
  16. package/icons/tsconfig.json +11 -0
  17. package/lefthook.yml +10 -0
  18. package/mise.toml +55 -0
  19. package/package.json +26 -0
  20. package/pnpm-workspace.yaml +9 -0
  21. package/prettier.config.mts +8 -0
  22. package/react/LICENCE +202 -0
  23. package/react/README.md +75 -0
  24. package/react/eslint.config.js +22 -0
  25. package/react/package.json +63 -0
  26. package/react/src/CAP.ts +14 -0
  27. package/react/src/Card.ts +2 -0
  28. package/react/src/DataSelectable.ts +7 -0
  29. package/react/src/Grid.ts +33 -0
  30. package/react/src/IpponBadge.tsx +62 -0
  31. package/react/src/IpponButton.tsx +93 -0
  32. package/react/src/IpponButtonCard.tsx +34 -0
  33. package/react/src/IpponCard.tsx +30 -0
  34. package/react/src/IpponContainer.tsx +15 -0
  35. package/react/src/IpponGrid.tsx +56 -0
  36. package/react/src/IpponHSpace.tsx +56 -0
  37. package/react/src/IpponIcon.tsx +15 -0
  38. package/react/src/IpponImportFile.tsx +128 -0
  39. package/react/src/IpponIon.tsx +45 -0
  40. package/react/src/IpponMeter.tsx +43 -0
  41. package/react/src/IpponProgress.tsx +45 -0
  42. package/react/src/IpponText.tsx +56 -0
  43. package/react/src/IpponTitle.tsx +45 -0
  44. package/react/src/IpponVSpace.tsx +43 -0
  45. package/react/src/Optional.ts +177 -0
  46. package/react/src/Tokens.ts +36 -0
  47. package/react/src/index.ts +16 -0
  48. package/react/test/File.fixture.ts +13 -0
  49. package/react/test/IpponBadge.spec.tsx +245 -0
  50. package/react/test/IpponButton.spec.tsx +666 -0
  51. package/react/test/IpponButtonCard.spec.tsx +162 -0
  52. package/react/test/IpponCard.spec.tsx +133 -0
  53. package/react/test/IpponContainer.spec.tsx +56 -0
  54. package/react/test/IpponGrid.spec.tsx +140 -0
  55. package/react/test/IpponHSpace.spec.tsx +107 -0
  56. package/react/test/IpponIcon.spec.tsx +37 -0
  57. package/react/test/IpponImportFile.spec.tsx +431 -0
  58. package/react/test/IpponIon.spec.tsx +52 -0
  59. package/react/test/IpponMeter.spec.tsx +59 -0
  60. package/react/test/IpponProgress.spec.tsx +68 -0
  61. package/react/test/IpponText.spec.tsx +149 -0
  62. package/react/test/IpponTitle.spec.tsx +242 -0
  63. package/react/test/IpponVSpace.spec.tsx +91 -0
  64. package/react/tsconfig.app.json +24 -0
  65. package/react/tsconfig.json +4 -0
  66. package/react/tsconfig.node.json +23 -0
  67. package/react/vite.config.ts +30 -0
  68. package/react/vitest.config.ts +21 -0
  69. package/styles/.editorconfig +12 -0
  70. package/styles/.stylelintrc.json +75 -0
  71. package/styles/LICENCE +202 -0
  72. package/styles/README.md +107 -0
  73. package/styles/logo.svg +26 -0
  74. package/styles/package.json +67 -0
  75. package/styles/src/atom/_atom.scss +9 -0
  76. package/styles/src/atom/atom.pug +34 -0
  77. package/styles/src/atom/badge/_badge.scss +108 -0
  78. package/styles/src/atom/badge/badge.code.pug +29 -0
  79. package/styles/src/atom/badge/badge.md +1 -0
  80. package/styles/src/atom/badge/badge.mixin.pug +24 -0
  81. package/styles/src/atom/badge/badge.render.pug +7 -0
  82. package/styles/src/atom/button/_button.scss +242 -0
  83. package/styles/src/atom/button/button.code.pug +38 -0
  84. package/styles/src/atom/button/button.md +31 -0
  85. package/styles/src/atom/button/button.mixin.pug +30 -0
  86. package/styles/src/atom/button/button.render.pug +13 -0
  87. package/styles/src/atom/icon/_icon.scss +8 -0
  88. package/styles/src/atom/icon/icon.code.pug +5 -0
  89. package/styles/src/atom/icon/icon.md +11 -0
  90. package/styles/src/atom/icon/icon.mixin.pug +8 -0
  91. package/styles/src/atom/icon/icon.render.pug +7 -0
  92. package/styles/src/atom/ion/ion.code.pug +8 -0
  93. package/styles/src/atom/ion/ion.md +11 -0
  94. package/styles/src/atom/ion/ion.mixin.pug +8 -0
  95. package/styles/src/atom/ion/ion.render.pug +7 -0
  96. package/styles/src/atom/meter/_meter.scss +23 -0
  97. package/styles/src/atom/meter/meter.code.pug +8 -0
  98. package/styles/src/atom/meter/meter.md +7 -0
  99. package/styles/src/atom/meter/meter.mixin.pug +12 -0
  100. package/styles/src/atom/meter/meter.render.pug +5 -0
  101. package/styles/src/atom/progress/_progress.scss +23 -0
  102. package/styles/src/atom/progress/progress.code.pug +8 -0
  103. package/styles/src/atom/progress/progress.md +7 -0
  104. package/styles/src/atom/progress/progress.mixin.pug +14 -0
  105. package/styles/src/atom/progress/progress.render.pug +5 -0
  106. package/styles/src/atom/tab/_tab.scss +48 -0
  107. package/styles/src/atom/tab/tab.code.pug +5 -0
  108. package/styles/src/atom/tab/tab.md +1 -0
  109. package/styles/src/atom/tab/tab.mixin.pug +14 -0
  110. package/styles/src/atom/tab/tab.render.pug +4 -0
  111. package/styles/src/atom/text/_text.scss +74 -0
  112. package/styles/src/atom/text/text.code.pug +19 -0
  113. package/styles/src/atom/text/text.md +5 -0
  114. package/styles/src/atom/text/text.mixin.pug +12 -0
  115. package/styles/src/atom/text/text.render.pug +7 -0
  116. package/styles/src/atom/title/_title.scss +68 -0
  117. package/styles/src/atom/title/title.code.pug +25 -0
  118. package/styles/src/atom/title/title.md +9 -0
  119. package/styles/src/atom/title/title.mixin.pug +12 -0
  120. package/styles/src/atom/title/title.render.pug +5 -0
  121. package/styles/src/atom/title-display/_title-display.scss +26 -0
  122. package/styles/src/atom/title-display/title-display.code.pug +9 -0
  123. package/styles/src/atom/title-display/title-display.md +5 -0
  124. package/styles/src/atom/title-display/title-display.mixin.pug +6 -0
  125. package/styles/src/atom/title-display/title-display.render.pug +4 -0
  126. package/styles/src/doc.scss +2 -0
  127. package/styles/src/favicon.ico +0 -0
  128. package/styles/src/function/_conversion.scss +9 -0
  129. package/styles/src/index.pug +59 -0
  130. package/styles/src/layout-documentation.pug +14 -0
  131. package/styles/src/layout.pug +17 -0
  132. package/styles/src/molecule/_molecule.scss +2 -0
  133. package/styles/src/molecule/import-file/_import-file.scss +38 -0
  134. package/styles/src/molecule/import-file/import-file.code.pug +4 -0
  135. package/styles/src/molecule/import-file/import-file.md +1 -0
  136. package/styles/src/molecule/import-file/import-file.mixin.pug +15 -0
  137. package/styles/src/molecule/import-file/import-file.render.pug +5 -0
  138. package/styles/src/molecule/molecule.pug +20 -0
  139. package/styles/src/molecule/tabs/_tabs.scss +4 -0
  140. package/styles/src/molecule/tabs/tabs.code.pug +9 -0
  141. package/styles/src/molecule/tabs/tabs.md +1 -0
  142. package/styles/src/molecule/tabs/tabs.mixin.pug +4 -0
  143. package/styles/src/molecule/tabs/tabs.render.pug +4 -0
  144. package/styles/src/molecule/toggle/_toggle.scss +68 -0
  145. package/styles/src/molecule/toggle/toggle.code.pug +26 -0
  146. package/styles/src/molecule/toggle/toggle.md +1 -0
  147. package/styles/src/molecule/toggle/toggle.mixin.pug +36 -0
  148. package/styles/src/molecule/toggle/toggle.render.pug +5 -0
  149. package/styles/src/organism/_abstract-card.scss +36 -0
  150. package/styles/src/organism/_docorganism.scss +1 -0
  151. package/styles/src/organism/_organism.scss +8 -0
  152. package/styles/src/organism/button-card/_button-card.scss +22 -0
  153. package/styles/src/organism/button-card/button-card.code.pug +31 -0
  154. package/styles/src/organism/button-card/button-card.md +28 -0
  155. package/styles/src/organism/button-card/button-card.mixin.pug +8 -0
  156. package/styles/src/organism/button-card/button-card.render.pug +7 -0
  157. package/styles/src/organism/card/_card.scss +6 -0
  158. package/styles/src/organism/card/card.code.pug +9 -0
  159. package/styles/src/organism/card/card.md +23 -0
  160. package/styles/src/organism/card/card.mixin.pug +7 -0
  161. package/styles/src/organism/card/card.render.pug +7 -0
  162. package/styles/src/organism/container/_container.scss +3 -0
  163. package/styles/src/organism/container/container.code.pug +13 -0
  164. package/styles/src/organism/container/container.md +5 -0
  165. package/styles/src/organism/container/container.mixin.pug +3 -0
  166. package/styles/src/organism/container/container.render.pug +4 -0
  167. package/styles/src/organism/grid/_docgrid.scss +11 -0
  168. package/styles/src/organism/grid/_grid.scss +84 -0
  169. package/styles/src/organism/grid/grid.code.pug +25 -0
  170. package/styles/src/organism/grid/grid.md +1 -0
  171. package/styles/src/organism/grid/grid.mixin.pug +7 -0
  172. package/styles/src/organism/grid/grid.render.pug +5 -0
  173. package/styles/src/organism/h-space/_h-space.scss +49 -0
  174. package/styles/src/organism/h-space/h-space.code.pug +56 -0
  175. package/styles/src/organism/h-space/h-space.md +22 -0
  176. package/styles/src/organism/h-space/h-space.mixin.pug +14 -0
  177. package/styles/src/organism/h-space/h-space.render.pug +5 -0
  178. package/styles/src/organism/header/_header.scss +8 -0
  179. package/styles/src/organism/header/header.code.pug +14 -0
  180. package/styles/src/organism/header/header.md +1 -0
  181. package/styles/src/organism/header/header.mixin.pug +7 -0
  182. package/styles/src/organism/header/header.render.pug +4 -0
  183. package/styles/src/organism/modal/_modal.scss +58 -0
  184. package/styles/src/organism/modal/modal.code.pug +68 -0
  185. package/styles/src/organism/modal/modal.md +1 -0
  186. package/styles/src/organism/modal/modal.mixin.pug +25 -0
  187. package/styles/src/organism/modal/modal.render.pug +4 -0
  188. package/styles/src/organism/organism.pug +30 -0
  189. package/styles/src/organism/v-space/_v-space.scss +45 -0
  190. package/styles/src/organism/v-space/v-space.code.pug +41 -0
  191. package/styles/src/organism/v-space/v-space.md +20 -0
  192. package/styles/src/organism/v-space/v-space.mixin.pug +7 -0
  193. package/styles/src/organism/v-space/v-space.render.pug +5 -0
  194. package/styles/src/quark/_breakpoint.scss +12 -0
  195. package/styles/src/quark/_font.scss +38 -0
  196. package/styles/src/quark/_gap.scss +34 -0
  197. package/styles/src/quark/_placeholder.scss +27 -0
  198. package/styles/src/quark/_shadow.scss +13 -0
  199. package/styles/src/quark/_typography.scss +146 -0
  200. package/styles/src/template/_template.scss +1 -0
  201. package/styles/src/template/layout/_layout.scss +20 -0
  202. package/styles/src/template/layout/layout.code.pug +11 -0
  203. package/styles/src/template/layout/layout.md +1 -0
  204. package/styles/src/template/layout/layout.mixin.pug +11 -0
  205. package/styles/src/template/layout/layout.render.pug +4 -0
  206. package/styles/src/template/template.pug +16 -0
  207. package/styles/src/tikui.scss +5 -0
  208. package/styles/src/token/_doctable.scss +14 -0
  209. package/styles/src/token/_doctoken.scss +1 -0
  210. package/styles/src/token/_size.scss +9 -0
  211. package/styles/src/token/_token.scss +5 -0
  212. package/styles/src/token/color/_color.scss +9 -0
  213. package/styles/src/token/color/color/_base.scss +65 -0
  214. package/styles/src/token/color/color/_brand.scss +13 -0
  215. package/styles/src/token/color/color/_error.scss +13 -0
  216. package/styles/src/token/color/color/_information-2.scss +13 -0
  217. package/styles/src/token/color/color/_information.scss +13 -0
  218. package/styles/src/token/color/color/_neutral.scss +20 -0
  219. package/styles/src/token/color/color/_semantic.scss +69 -0
  220. package/styles/src/token/color/color/_success.scss +13 -0
  221. package/styles/src/token/color/color/_warning.scss +13 -0
  222. package/styles/src/token/color/color.js +31 -0
  223. package/styles/src/token/color/color.mixin.pug +19 -0
  224. package/styles/src/token/color/color.pug +9 -0
  225. package/styles/src/token/color/color.render.pug +13 -0
  226. package/styles/src/token/radius/_radius.scss +8 -0
  227. package/styles/src/token/radius/radius.js +54 -0
  228. package/styles/src/token/radius/radius.mixin.pug +14 -0
  229. package/styles/src/token/radius/radius.pug +9 -0
  230. package/styles/src/token/radius/radius.render.pug +11 -0
  231. package/styles/src/token/shadow/_shadow.scss +22 -0
  232. package/styles/src/token/shadow/shadow.js +45 -0
  233. package/styles/src/token/shadow/shadow.mixin.pug +13 -0
  234. package/styles/src/token/shadow/shadow.pug +9 -0
  235. package/styles/src/token/shadow/shadow.render.pug +9 -0
  236. package/styles/src/token/token.js +38 -0
  237. package/styles/src/token/token.pug +25 -0
  238. package/styles/src/token/typography/_typography.scss +103 -0
  239. package/styles/src/token/typography/typography.js +32 -0
  240. package/styles/src/token/typography/typography.mixin.pug +17 -0
  241. package/styles/src/token/typography/typography.pug +9 -0
  242. package/styles/src/token/typography/typography.render.pug +19 -0
  243. package/styles/test/function/conversion.test.scss +20 -0
  244. package/styles/test/function/sass.spec.ts +6 -0
  245. package/styles/tikuiconfig.json +14 -0
  246. package/styles/tsconfig.json +10 -0
@@ -0,0 +1,666 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { act, render, screen, configure, cleanup } from '@testing-library/react';
3
+ import '@testing-library/jest-dom/vitest';
4
+ import { IpponButton } from '../src';
5
+
6
+ configure({
7
+ testIdAttribute: 'data-selector',
8
+ });
9
+
10
+ const getIpponButton = () => screen.getByTestId('ippon-button');
11
+
12
+ const expectToHaveClasses = (...classes: string[]) =>
13
+ expect(getIpponButton()).toHaveClass('ippon-button', ...classes);
14
+
15
+ const expectNotToHaveClasses = (...classes: string[]) => {
16
+ const button = getIpponButton();
17
+ classes.forEach((cls) => expect(button).not.toHaveClass(cls));
18
+ };
19
+
20
+ const expectToHaveTextContent = (text: string) => expect(getIpponButton()).toHaveTextContent(text);
21
+
22
+ const getTextPart = () => getIpponButton().querySelector('.ippon-button--text');
23
+
24
+ const expectToHaveTextPart = () => expect(getTextPart()).toBeInTheDocument();
25
+
26
+ const getRightIcon = () => getIpponButton().querySelector('.ippon-button--icon:last-of-type');
27
+
28
+ const getLeftIcon = () => getIpponButton().querySelector('.ippon-button--icon:first-of-type');
29
+
30
+ const getIconParts = () => getIpponButton().querySelectorAll<HTMLElement>('.ippon-button--icon');
31
+
32
+ const expectIconPartsCount = (count: number) => expect(getIconParts()).toHaveLength(count);
33
+
34
+ describe('IpponButton', () => {
35
+ afterEach(cleanup);
36
+
37
+ it('should be minimal', () => {
38
+ render(<IpponButton dataSelector="ippon-button">Default</IpponButton>);
39
+
40
+ expectToHaveClasses();
41
+ expectToHaveTextContent('Default');
42
+ });
43
+
44
+ it('should click', () => {
45
+ const onClick = vi.fn();
46
+
47
+ render(
48
+ <IpponButton onClick={onClick} dataSelector="ippon-button">
49
+ Click me
50
+ </IpponButton>,
51
+ );
52
+
53
+ getIpponButton().click();
54
+
55
+ expect(onClick).toHaveBeenCalled();
56
+ });
57
+
58
+ describe('Variant', () => {
59
+ it('should be secondary', () => {
60
+ render(
61
+ <IpponButton variant="secondary" dataSelector="ippon-button">
62
+ Secondary
63
+ </IpponButton>,
64
+ );
65
+
66
+ expectToHaveClasses('-secondary');
67
+ });
68
+
69
+ it('should be outline', () => {
70
+ render(
71
+ <IpponButton variant="outline" dataSelector="ippon-button">
72
+ Outline
73
+ </IpponButton>,
74
+ );
75
+
76
+ expectToHaveClasses('-outline');
77
+ });
78
+
79
+ it('should be text', () => {
80
+ render(
81
+ <IpponButton variant="text" dataSelector="ippon-button">
82
+ Text
83
+ </IpponButton>,
84
+ );
85
+
86
+ expectToHaveClasses('-text');
87
+ });
88
+ });
89
+
90
+ describe('Color', () => {
91
+ it.each(['success', 'error', 'information', 'warning', 'neutral'] as const)(
92
+ 'should be %s',
93
+ (color) => {
94
+ render(
95
+ <IpponButton color={color} dataSelector="ippon-button">
96
+ {color}
97
+ </IpponButton>,
98
+ );
99
+
100
+ expectToHaveClasses(`-${color}`);
101
+ },
102
+ );
103
+ });
104
+
105
+ describe('Size', () => {
106
+ it('should not have size class by default', () => {
107
+ render(<IpponButton dataSelector="ippon-button">Default</IpponButton>);
108
+
109
+ expectNotToHaveClasses('-small', '-large');
110
+ });
111
+
112
+ it.each(['small', 'large'] as const)('should be %s', (size) => {
113
+ render(
114
+ <IpponButton size={size} dataSelector="ippon-button">
115
+ {size}
116
+ </IpponButton>,
117
+ );
118
+
119
+ expectToHaveClasses(`-${size}`);
120
+ });
121
+ });
122
+
123
+ describe('Disabled', () => {
124
+ it('should not be disabled by default', () => {
125
+ render(<IpponButton dataSelector="ippon-button">Default</IpponButton>);
126
+
127
+ expect(getIpponButton()).not.toBeDisabled();
128
+ });
129
+
130
+ it('should be disabled', () => {
131
+ render(
132
+ <IpponButton disabled dataSelector="ippon-button">
133
+ Disabled
134
+ </IpponButton>,
135
+ );
136
+
137
+ expect(getIpponButton()).toBeDisabled();
138
+ });
139
+
140
+ it('should not call onClick when disabled', () => {
141
+ const onClick = vi.fn();
142
+
143
+ render(
144
+ <IpponButton disabled onClick={onClick} dataSelector="ippon-button">
145
+ Disabled
146
+ </IpponButton>,
147
+ );
148
+
149
+ getIpponButton().click();
150
+
151
+ expect(onClick).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it('should be disabled while loading', async () => {
155
+ let resolvePromise: () => void;
156
+ const promise = new Promise<void>((resolve) => {
157
+ resolvePromise = resolve;
158
+ });
159
+ const onClick = () => promise;
160
+
161
+ render(
162
+ <IpponButton onClick={onClick} dataSelector="ippon-button">
163
+ Async
164
+ </IpponButton>,
165
+ );
166
+
167
+ act(() => {
168
+ getIpponButton().click();
169
+ });
170
+
171
+ expect(getIpponButton()).toBeDisabled();
172
+
173
+ await act(() => {
174
+ resolvePromise!();
175
+ return promise;
176
+ });
177
+ });
178
+ });
179
+
180
+ describe('Icons', () => {
181
+ it('should have icon left', () => {
182
+ render(
183
+ <IpponButton iconLeft={{ name: 'heart' }} dataSelector="ippon-button">
184
+ With left icon
185
+ </IpponButton>,
186
+ );
187
+
188
+ expectToHaveTextPart();
189
+ expectIconPartsCount(1);
190
+ });
191
+
192
+ it('should have icon right', () => {
193
+ render(
194
+ <IpponButton iconRight={{ name: 'search' }} dataSelector="ippon-button">
195
+ With right icon
196
+ </IpponButton>,
197
+ );
198
+
199
+ expectToHaveTextPart();
200
+ expectIconPartsCount(1);
201
+ });
202
+
203
+ it('should have both icons', () => {
204
+ render(
205
+ <IpponButton
206
+ iconLeft={{ name: 'remove' }}
207
+ iconRight={{ name: 'add' }}
208
+ dataSelector="ippon-button"
209
+ >
210
+ With both icons
211
+ </IpponButton>,
212
+ );
213
+
214
+ expectToHaveTextPart();
215
+ expectIconPartsCount(2);
216
+ });
217
+
218
+ it('should not have text part without icons', () => {
219
+ render(<IpponButton dataSelector="ippon-button">No icon</IpponButton>);
220
+
221
+ expect(getTextPart()).not.toBeInTheDocument();
222
+ });
223
+
224
+ it('should not have text part with icon but without children', () => {
225
+ render(<IpponButton iconLeft={{ name: 'heart' }} dataSelector="ippon-button" />);
226
+
227
+ expect(getTextPart()).not.toBeInTheDocument();
228
+ });
229
+
230
+ it('should have icon when iconLeft is defined without children', () => {
231
+ render(<IpponButton iconLeft={{ name: 'heart' }} dataSelector="ippon-button" />);
232
+
233
+ expectIconPartsCount(1);
234
+ });
235
+
236
+ it('should have icon when iconRight is defined without children', () => {
237
+ render(<IpponButton iconRight={{ name: 'search' }} dataSelector="ippon-button" />);
238
+
239
+ expectIconPartsCount(1);
240
+ });
241
+ });
242
+
243
+ describe('Loading (async onClick)', () => {
244
+ it('should not be loading by default', () => {
245
+ render(<IpponButton dataSelector="ippon-button">Default</IpponButton>);
246
+
247
+ expectNotToHaveClasses('-loading');
248
+ const ariaValue = getIpponButton().getAttribute('aria-busy');
249
+ expect(ariaValue).toBeNull();
250
+ });
251
+
252
+ it('should not be loading with sync onClick', () => {
253
+ const onClick = vi.fn();
254
+
255
+ render(
256
+ <IpponButton onClick={onClick} dataSelector="ippon-button">
257
+ Sync
258
+ </IpponButton>,
259
+ );
260
+
261
+ getIpponButton().click();
262
+
263
+ expectNotToHaveClasses('-loading');
264
+ const ariaValue = getIpponButton().getAttribute('aria-busy');
265
+ expect(ariaValue).toBeNull();
266
+ expect(getIpponButton()).not.toBeDisabled();
267
+ });
268
+
269
+ it('should be loading during async onClick', async () => {
270
+ let resolvePromise: () => void;
271
+ const promise = new Promise<void>((resolve) => {
272
+ resolvePromise = resolve;
273
+ });
274
+ const onClick = () => promise;
275
+
276
+ render(
277
+ <IpponButton onClick={onClick} dataSelector="ippon-button">
278
+ Async
279
+ </IpponButton>,
280
+ );
281
+
282
+ act(() => {
283
+ getIpponButton().click();
284
+ });
285
+
286
+ expectToHaveClasses('-loading');
287
+ expect(getIpponButton()).toHaveAttribute('aria-busy', 'true');
288
+ expect(getIpponButton()).toBeDisabled();
289
+
290
+ await act(() => {
291
+ resolvePromise!();
292
+ return promise;
293
+ });
294
+
295
+ expectNotToHaveClasses('-loading');
296
+ const ariaValue = getIpponButton().getAttribute('aria-busy');
297
+ expect(ariaValue).toBeNull();
298
+ expect(getIpponButton()).not.toBeDisabled();
299
+ });
300
+
301
+ it('should stop loading after async onClick rejection', async () => {
302
+ let rejectPromise: () => void;
303
+ const promise = new Promise<void>((_, reject) => {
304
+ rejectPromise = reject;
305
+ });
306
+ const onClick = () => promise;
307
+
308
+ render(
309
+ <IpponButton onClick={onClick} dataSelector="ippon-button">
310
+ Async error
311
+ </IpponButton>,
312
+ );
313
+
314
+ act(() => {
315
+ getIpponButton().click();
316
+ });
317
+
318
+ expectToHaveClasses('-loading');
319
+
320
+ await act(async () => {
321
+ rejectPromise!();
322
+ try {
323
+ await promise;
324
+ } catch {
325
+ // Expected rejection
326
+ }
327
+ });
328
+
329
+ expectNotToHaveClasses('-loading');
330
+ const ariaValue = getIpponButton().getAttribute('aria-busy');
331
+ expect(ariaValue).toBeNull();
332
+ expect(getIpponButton()).not.toBeDisabled();
333
+ });
334
+
335
+ it('should not trigger onClick while loading', async () => {
336
+ let resolvePromise: () => void;
337
+ const promise = new Promise<void>((resolve) => {
338
+ resolvePromise = resolve;
339
+ });
340
+ const onClick = vi.fn(() => promise);
341
+
342
+ render(
343
+ <IpponButton onClick={onClick} dataSelector="ippon-button">
344
+ Async
345
+ </IpponButton>,
346
+ );
347
+
348
+ act(() => {
349
+ getIpponButton().click();
350
+ });
351
+
352
+ expect(onClick).toHaveBeenCalledTimes(1);
353
+
354
+ act(() => {
355
+ getIpponButton().click();
356
+ });
357
+
358
+ expect(onClick).toHaveBeenCalledTimes(1);
359
+
360
+ await act(() => {
361
+ resolvePromise!();
362
+ return promise;
363
+ });
364
+ });
365
+
366
+ it('should handle click without onClick callback', () => {
367
+ render(<IpponButton dataSelector="ippon-button">No callback</IpponButton>);
368
+
369
+ // Should not throw or cause any issues
370
+ expect(() => {
371
+ getIpponButton().click();
372
+ }).not.toThrow();
373
+ });
374
+
375
+ it('should handle onClick that returns void (not a promise)', () => {
376
+ const onClick = vi.fn(() => {
377
+ // Return void, not a promise
378
+ });
379
+
380
+ render(
381
+ <IpponButton onClick={onClick} dataSelector="ippon-button">
382
+ Sync void
383
+ </IpponButton>,
384
+ );
385
+
386
+ getIpponButton().click();
387
+
388
+ expect(onClick).toHaveBeenCalledTimes(1);
389
+ expectNotToHaveClasses('-loading');
390
+ const ariaValue = getIpponButton().getAttribute('aria-busy');
391
+ expect(ariaValue).toBeNull();
392
+ });
393
+
394
+ it('should not be clickable while loading from previous async call', async () => {
395
+ let resolvePromise: () => void;
396
+ const promise = new Promise<void>((resolve) => {
397
+ resolvePromise = resolve;
398
+ });
399
+ const onClick = vi.fn(() => promise);
400
+
401
+ render(
402
+ <IpponButton onClick={onClick} dataSelector="ippon-button">
403
+ Async button
404
+ </IpponButton>,
405
+ );
406
+
407
+ act(() => {
408
+ getIpponButton().click();
409
+ });
410
+
411
+ expectToHaveClasses('-loading');
412
+ expect(getIpponButton()).toBeDisabled();
413
+
414
+ // Try to click while loading
415
+ act(() => {
416
+ getIpponButton().click();
417
+ });
418
+
419
+ // Should still only have been called once
420
+ expect(onClick).toHaveBeenCalledTimes(1);
421
+
422
+ await act(() => {
423
+ resolvePromise!();
424
+ return promise;
425
+ });
426
+
427
+ expectNotToHaveClasses('-loading');
428
+ expect(getIpponButton()).not.toBeDisabled();
429
+ });
430
+
431
+ it('should replace right icon with sync icon during loading', async () => {
432
+ let resolvePromise: () => void;
433
+ const promise = new Promise<void>((resolve) => {
434
+ resolvePromise = resolve;
435
+ });
436
+ const onClick = () => promise;
437
+
438
+ render(
439
+ <IpponButton onClick={onClick} iconRight={{ name: 'add' }} dataSelector="ippon-button">
440
+ Async
441
+ </IpponButton>,
442
+ );
443
+
444
+ const rightIcon = getRightIcon();
445
+ expect(rightIcon).toHaveClass('ippon-ion-add');
446
+ expect(rightIcon).not.toHaveClass('-loading');
447
+
448
+ act(() => {
449
+ getIpponButton().click();
450
+ });
451
+
452
+ const loadingRightIcon = getRightIcon();
453
+ expect(loadingRightIcon).toHaveClass('ippon-ion-sync');
454
+ expect(loadingRightIcon).toHaveClass('-loading');
455
+ expect(loadingRightIcon).not.toHaveClass('ippon-ion-add');
456
+
457
+ await act(() => {
458
+ resolvePromise!();
459
+ return promise;
460
+ });
461
+
462
+ const restoredRightIcon = getRightIcon();
463
+ expect(restoredRightIcon).toHaveClass('ippon-ion-add');
464
+ expect(restoredRightIcon).not.toHaveClass('ippon-ion-sync');
465
+ expect(restoredRightIcon).not.toHaveClass('-loading');
466
+ });
467
+
468
+ it('should not have loading class on left icon during loading', async () => {
469
+ let resolvePromise: () => void;
470
+ const promise = new Promise<void>((resolve) => {
471
+ resolvePromise = resolve;
472
+ });
473
+ const onClick = () => promise;
474
+
475
+ render(
476
+ <IpponButton
477
+ onClick={onClick}
478
+ iconLeft={{ name: 'remove' }}
479
+ iconRight={{ name: 'add' }}
480
+ dataSelector="ippon-button"
481
+ >
482
+ Async
483
+ </IpponButton>,
484
+ );
485
+
486
+ act(() => {
487
+ getIpponButton().click();
488
+ });
489
+
490
+ const leftIcon = getLeftIcon();
491
+ expect(leftIcon).toHaveClass('ippon-ion-remove');
492
+ expect(leftIcon).not.toHaveClass('-loading');
493
+
494
+ const loadingRightIcon = getRightIcon();
495
+ expect(loadingRightIcon).toHaveClass('ippon-ion-sync');
496
+ expect(loadingRightIcon).toHaveClass('-loading');
497
+
498
+ await act(() => {
499
+ resolvePromise!();
500
+ return promise;
501
+ });
502
+ });
503
+
504
+ it('should not replace right icon when not loading', () => {
505
+ render(
506
+ <IpponButton iconRight={{ name: 'add' }} dataSelector="ippon-button">
507
+ Not loading
508
+ </IpponButton>,
509
+ );
510
+
511
+ const rightIcon = getRightIcon();
512
+ expect(rightIcon).toHaveClass('ippon-ion-add');
513
+ expect(rightIcon).not.toHaveClass('ippon-ion-sync');
514
+ });
515
+
516
+ it('should not add sync icon during loading without right icon', async () => {
517
+ let resolvePromise: () => void;
518
+ const promise = new Promise<void>((resolve) => {
519
+ resolvePromise = resolve;
520
+ });
521
+ const onClick = () => promise;
522
+
523
+ render(
524
+ <IpponButton onClick={onClick} dataSelector="ippon-button">
525
+ Async
526
+ </IpponButton>,
527
+ );
528
+
529
+ act(() => {
530
+ getIpponButton().click();
531
+ });
532
+
533
+ expectIconPartsCount(0);
534
+
535
+ await act(() => {
536
+ resolvePromise!();
537
+ return promise;
538
+ });
539
+ });
540
+ });
541
+
542
+ describe('Combinations', () => {
543
+ it('should combine color, variant and size', () => {
544
+ render(
545
+ <IpponButton color="success" variant="outline" size="large" dataSelector="ippon-button">
546
+ Combined
547
+ </IpponButton>,
548
+ );
549
+
550
+ expectToHaveClasses('-success', '-outline', '-large');
551
+ });
552
+
553
+ it('should combine color, variant and icon', () => {
554
+ render(
555
+ <IpponButton
556
+ color="error"
557
+ variant="secondary"
558
+ iconLeft={{ name: 'alert-circle', variant: 'outline' }}
559
+ dataSelector="ippon-button"
560
+ >
561
+ Error with icon
562
+ </IpponButton>,
563
+ );
564
+
565
+ expectToHaveClasses('-error', '-secondary');
566
+ expectToHaveTextPart();
567
+ expectIconPartsCount(1);
568
+ });
569
+
570
+ it('should render children without text wrapper when no icon', () => {
571
+ render(<IpponButton dataSelector="ippon-button">No icon text</IpponButton>);
572
+
573
+ // When no icon, children are rendered directly (not wrapped in .ippon-button--text)
574
+ expect(getTextPart()).not.toBeInTheDocument();
575
+ expect(getIpponButton()).toHaveTextContent('No icon text');
576
+ });
577
+
578
+ it('should render children in text wrapper when icon exists', () => {
579
+ render(
580
+ <IpponButton iconLeft={{ name: 'heart' }} dataSelector="ippon-button">
581
+ With icon text
582
+ </IpponButton>,
583
+ );
584
+
585
+ // When icon exists, children are wrapped in .ippon-button--text
586
+ expectToHaveTextPart();
587
+ expect(getTextPart()).toHaveTextContent('With icon text');
588
+ });
589
+
590
+ it('should render both children and text wrapper when both icons exist', () => {
591
+ render(
592
+ <IpponButton
593
+ iconLeft={{ name: 'heart' }}
594
+ iconRight={{ name: 'search' }}
595
+ dataSelector="ippon-button"
596
+ >
597
+ Both icons text
598
+ </IpponButton>,
599
+ );
600
+
601
+ expectToHaveTextPart();
602
+ expect(getTextPart()).toHaveTextContent('Both icons text');
603
+ expectIconPartsCount(2);
604
+ });
605
+
606
+ it('should render icon even without children', () => {
607
+ render(<IpponButton iconLeft={{ name: 'heart' }} dataSelector="ippon-button" />);
608
+
609
+ expectIconPartsCount(1);
610
+ expect(getTextPart()).not.toBeInTheDocument();
611
+ });
612
+
613
+ it('should render right icon even without children', () => {
614
+ render(<IpponButton iconRight={{ name: 'search' }} dataSelector="ippon-button" />);
615
+
616
+ expectIconPartsCount(1);
617
+ expect(getTextPart()).not.toBeInTheDocument();
618
+ });
619
+
620
+ it('should render disabled button with icon', () => {
621
+ render(
622
+ <IpponButton disabled iconLeft={{ name: 'heart' }} dataSelector="ippon-button">
623
+ Disabled with icon
624
+ </IpponButton>,
625
+ );
626
+
627
+ expect(getIpponButton()).toBeDisabled();
628
+ expectToHaveTextPart();
629
+ expectIconPartsCount(1);
630
+ });
631
+
632
+ it('should handle onClick that returns non-Promise object', () => {
633
+ const onClick = vi.fn(() => {
634
+ // Return void (undefined), not a promise
635
+ });
636
+
637
+ render(
638
+ <IpponButton onClick={onClick} dataSelector="ippon-button">
639
+ Non-promise object
640
+ </IpponButton>,
641
+ );
642
+
643
+ getIpponButton().click();
644
+
645
+ expect(onClick).toHaveBeenCalledTimes(1);
646
+ expectNotToHaveClasses('-loading');
647
+ const ariaValue = getIpponButton().getAttribute('aria-busy');
648
+ expect(ariaValue).toBeNull();
649
+ });
650
+
651
+ it('should handle onClick that returns undefined', () => {
652
+ const onClick = vi.fn(() => undefined);
653
+
654
+ render(
655
+ <IpponButton onClick={onClick} dataSelector="ippon-button">
656
+ Undefined return
657
+ </IpponButton>,
658
+ );
659
+
660
+ getIpponButton().click();
661
+
662
+ expect(onClick).toHaveBeenCalledTimes(1);
663
+ expectNotToHaveClasses('-loading');
664
+ });
665
+ });
666
+ });