@ayasofyazilim/ui 0.0.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 (236) hide show
  1. package/__mocks__/canvas.ts +8 -0
  2. package/components.json +21 -0
  3. package/eslint.config.js +4 -0
  4. package/jest-environment.js +37 -0
  5. package/jest.config.ts +47 -0
  6. package/jest.setup.ts +69 -0
  7. package/package.json +124 -0
  8. package/postcss.config.mjs +6 -0
  9. package/src/aria/index.tsx +1 -0
  10. package/src/aria/number-field.tsx +41 -0
  11. package/src/components/.gitkeep +0 -0
  12. package/src/components/accordion.tsx +66 -0
  13. package/src/components/alert-dialog.tsx +157 -0
  14. package/src/components/alert.tsx +70 -0
  15. package/src/components/aspect-ratio.tsx +11 -0
  16. package/src/components/avatar.tsx +53 -0
  17. package/src/components/badge.tsx +67 -0
  18. package/src/components/breadcrumb.tsx +109 -0
  19. package/src/components/button-group.tsx +83 -0
  20. package/src/components/button.tsx +68 -0
  21. package/src/components/calendar.tsx +219 -0
  22. package/src/components/card.tsx +92 -0
  23. package/src/components/carousel.tsx +241 -0
  24. package/src/components/chart.tsx +363 -0
  25. package/src/components/checkbox.tsx +32 -0
  26. package/src/components/collapsible.tsx +33 -0
  27. package/src/components/command.tsx +184 -0
  28. package/src/components/context-menu.tsx +252 -0
  29. package/src/components/dialog.tsx +144 -0
  30. package/src/components/drawer.tsx +135 -0
  31. package/src/components/dropdown-menu.tsx +258 -0
  32. package/src/components/empty.tsx +100 -0
  33. package/src/components/field.tsx +248 -0
  34. package/src/components/form.tsx +169 -0
  35. package/src/components/hover-card.tsx +44 -0
  36. package/src/components/input-group.tsx +170 -0
  37. package/src/components/input-otp.tsx +77 -0
  38. package/src/components/input.tsx +21 -0
  39. package/src/components/item.tsx +193 -0
  40. package/src/components/kbd.tsx +28 -0
  41. package/src/components/label.tsx +24 -0
  42. package/src/components/menubar.tsx +276 -0
  43. package/src/components/navigation-menu.tsx +168 -0
  44. package/src/components/pagination.tsx +130 -0
  45. package/src/components/popover.tsx +88 -0
  46. package/src/components/progress.tsx +31 -0
  47. package/src/components/radio-group.tsx +45 -0
  48. package/src/components/resizable.tsx +56 -0
  49. package/src/components/scroll-area.tsx +58 -0
  50. package/src/components/select.tsx +189 -0
  51. package/src/components/separator.tsx +28 -0
  52. package/src/components/sheet.tsx +140 -0
  53. package/src/components/sidebar.tsx +862 -0
  54. package/src/components/skeleton.tsx +13 -0
  55. package/src/components/slider.tsx +63 -0
  56. package/src/components/sonner.tsx +40 -0
  57. package/src/components/spinner.tsx +16 -0
  58. package/src/components/stepper.tsx +291 -0
  59. package/src/components/switch.tsx +31 -0
  60. package/src/components/table.tsx +133 -0
  61. package/src/components/tabs.tsx +66 -0
  62. package/src/components/textarea.tsx +18 -0
  63. package/src/components/toggle-group.tsx +83 -0
  64. package/src/components/toggle.tsx +47 -0
  65. package/src/components/tooltip.tsx +66 -0
  66. package/src/custom/action-button.tsx +48 -0
  67. package/src/custom/async-select.tsx +287 -0
  68. package/src/custom/awesome-not-found.tsx +116 -0
  69. package/src/custom/charts/area-chart.tsx +147 -0
  70. package/src/custom/charts/bar-chart.tsx +233 -0
  71. package/src/custom/charts/chart-card.tsx +103 -0
  72. package/src/custom/charts/index.tsx +16 -0
  73. package/src/custom/charts/pie-chart.tsx +168 -0
  74. package/src/custom/charts/radar-chart.tsx +126 -0
  75. package/src/custom/checkbox-tree.tsx +100 -0
  76. package/src/custom/combobox.tsx +296 -0
  77. package/src/custom/confirm-dialog.tsx +102 -0
  78. package/src/custom/country-selector.tsx +204 -0
  79. package/src/custom/date-picker/calendar-rac.tsx +109 -0
  80. package/src/custom/date-picker/datefield-rac.tsx +84 -0
  81. package/src/custom/date-picker/index.tsx +273 -0
  82. package/src/custom/date-picker/types/index.ts +4 -0
  83. package/src/custom/date-picker/utils/index.ts +42 -0
  84. package/src/custom/date-picker-old.tsx +50 -0
  85. package/src/custom/date-tooltip.tsx +98 -0
  86. package/src/custom/document-scanner/consts.ts +5 -0
  87. package/src/custom/document-scanner/corner-adjustment/action-buttons.tsx +33 -0
  88. package/src/custom/document-scanner/corner-adjustment/corner-handle.tsx +43 -0
  89. package/src/custom/document-scanner/corner-adjustment/hooks/use-corner-drag.ts +85 -0
  90. package/src/custom/document-scanner/corner-adjustment/index.tsx +125 -0
  91. package/src/custom/document-scanner/corner-adjustment/types.ts +53 -0
  92. package/src/custom/document-scanner/corner-adjustment/utils/clip-path.ts +22 -0
  93. package/src/custom/document-scanner/corner-adjustment/zoom-magnifier.tsx +115 -0
  94. package/src/custom/document-scanner/hooks/use-document-capture.ts +81 -0
  95. package/src/custom/document-scanner/hooks/use-document-scanner.ts +80 -0
  96. package/src/custom/document-scanner/hooks/use-perspective-crop.ts +38 -0
  97. package/src/custom/document-scanner/index.tsx +255 -0
  98. package/src/custom/document-scanner/lib.ts +407 -0
  99. package/src/custom/document-scanner/types.ts +205 -0
  100. package/src/custom/document-scanner/utils/perspective-correction.ts +139 -0
  101. package/src/custom/document-viewer/controllers.tsx +98 -0
  102. package/src/custom/document-viewer/index.tsx +43 -0
  103. package/src/custom/document-viewer/renderers/image.tsx +37 -0
  104. package/src/custom/document-viewer/renderers/index.tsx +2 -0
  105. package/src/custom/document-viewer/renderers/pdf.tsx +105 -0
  106. package/src/custom/email-input/domains.json +159 -0
  107. package/src/custom/email-input/email.tsx +229 -0
  108. package/src/custom/email-input/index.tsx +4 -0
  109. package/src/custom/email-input/types.ts +104 -0
  110. package/src/custom/file-uploader.tsx +541 -0
  111. package/src/custom/filter-component/fields/async-select.tsx +33 -0
  112. package/src/custom/filter-component/fields/date.tsx +60 -0
  113. package/src/custom/filter-component/fields/multi-select.tsx +30 -0
  114. package/src/custom/filter-component/index.tsx +217 -0
  115. package/src/custom/image-canvas.tsx +260 -0
  116. package/src/custom/json-editor.tsx +22 -0
  117. package/src/custom/master-data-grid/components/dialogs/column-settings-dialog.tsx +100 -0
  118. package/src/custom/master-data-grid/components/dialogs/index.ts +1 -0
  119. package/src/custom/master-data-grid/components/filters/client-filter.tsx +368 -0
  120. package/src/custom/master-data-grid/components/filters/filter-input.tsx +256 -0
  121. package/src/custom/master-data-grid/components/filters/index.ts +3 -0
  122. package/src/custom/master-data-grid/components/filters/inline-column-filter.tsx +233 -0
  123. package/src/custom/master-data-grid/components/filters/multi-filter-dialog.tsx +90 -0
  124. package/src/custom/master-data-grid/components/filters/server-filter.tsx +255 -0
  125. package/src/custom/master-data-grid/components/master-data-grid.tsx +472 -0
  126. package/src/custom/master-data-grid/components/pagination/index.ts +1 -0
  127. package/src/custom/master-data-grid/components/pagination/pagination.tsx +178 -0
  128. package/src/custom/master-data-grid/components/table/cell-renderer.tsx +634 -0
  129. package/src/custom/master-data-grid/components/table/header-cell.tsx +162 -0
  130. package/src/custom/master-data-grid/components/table/index.ts +4 -0
  131. package/src/custom/master-data-grid/components/table/table-body-renderer.tsx +113 -0
  132. package/src/custom/master-data-grid/components/table/virtual-body.tsx +138 -0
  133. package/src/custom/master-data-grid/components/toolbar/index.ts +1 -0
  134. package/src/custom/master-data-grid/components/toolbar/toolbar.tsx +314 -0
  135. package/src/custom/master-data-grid/hooks/index.ts +3 -0
  136. package/src/custom/master-data-grid/hooks/use-columns.tsx +332 -0
  137. package/src/custom/master-data-grid/hooks/use-editing.ts +106 -0
  138. package/src/custom/master-data-grid/hooks/use-table-state-reducer.ts +157 -0
  139. package/src/custom/master-data-grid/hooks/use-table-state.ts +31 -0
  140. package/src/custom/master-data-grid/index.ts +16 -0
  141. package/src/custom/master-data-grid/types.ts +466 -0
  142. package/src/custom/master-data-grid/utils/column-generator.tsx +306 -0
  143. package/src/custom/master-data-grid/utils/export-utils.ts +67 -0
  144. package/src/custom/master-data-grid/utils/filter-fns.ts +290 -0
  145. package/src/custom/master-data-grid/utils/index.ts +8 -0
  146. package/src/custom/master-data-grid/utils/pinning-utils.ts +88 -0
  147. package/src/custom/master-data-grid/utils/translation-utils.ts +42 -0
  148. package/src/custom/multi-select.tsx +432 -0
  149. package/src/custom/password-input.tsx +194 -0
  150. package/src/custom/phone-input.tsx +172 -0
  151. package/src/custom/schema-form/custom/index.tsx +1 -0
  152. package/src/custom/schema-form/custom/label.tsx +53 -0
  153. package/src/custom/schema-form/fields/base-input-field.tsx +82 -0
  154. package/src/custom/schema-form/fields/field.tsx +67 -0
  155. package/src/custom/schema-form/fields/index.tsx +5 -0
  156. package/src/custom/schema-form/fields/object.tsx +12 -0
  157. package/src/custom/schema-form/fields/table-array/array-field-item.tsx +90 -0
  158. package/src/custom/schema-form/fields/table-array/array-field-template.tsx +115 -0
  159. package/src/custom/schema-form/index.tsx +259 -0
  160. package/src/custom/schema-form/templates/description.tsx +20 -0
  161. package/src/custom/schema-form/templates/index.tsx +2 -0
  162. package/src/custom/schema-form/templates/submit.tsx +32 -0
  163. package/src/custom/schema-form/types.ts +64 -0
  164. package/src/custom/schema-form/utils/index.ts +4 -0
  165. package/src/custom/schema-form/utils/schema-dependency.ts +655 -0
  166. package/src/custom/schema-form/utils/schemas.ts +289 -0
  167. package/src/custom/schema-form/utils/validation.ts +23 -0
  168. package/src/custom/schema-form/widgets/boolean.tsx +77 -0
  169. package/src/custom/schema-form/widgets/combobox.tsx +274 -0
  170. package/src/custom/schema-form/widgets/date.tsx +59 -0
  171. package/src/custom/schema-form/widgets/email.tsx +34 -0
  172. package/src/custom/schema-form/widgets/index.tsx +10 -0
  173. package/src/custom/schema-form/widgets/password.tsx +40 -0
  174. package/src/custom/schema-form/widgets/phone.tsx +40 -0
  175. package/src/custom/schema-form/widgets/select.tsx +105 -0
  176. package/src/custom/schema-form/widgets/selectable.tsx +25 -0
  177. package/src/custom/schema-form/widgets/string-array.tsx +296 -0
  178. package/src/custom/schema-form/widgets/url.tsx +56 -0
  179. package/src/custom/section-layout-v2.tsx +212 -0
  180. package/src/custom/select-tabs.tsx +109 -0
  181. package/src/custom/selectable.tsx +316 -0
  182. package/src/custom/stepper.tsx +236 -0
  183. package/src/custom/tab-layout.tsx +213 -0
  184. package/src/custom/tanstack-table/fields/index.tsx +12 -0
  185. package/src/custom/tanstack-table/fields/tanstack-table-action-dialogs.tsx +89 -0
  186. package/src/custom/tanstack-table/fields/tanstack-table-column-header.tsx +66 -0
  187. package/src/custom/tanstack-table/fields/tanstack-table-filter-date.tsx +180 -0
  188. package/src/custom/tanstack-table/fields/tanstack-table-filter-faceted.tsx +158 -0
  189. package/src/custom/tanstack-table/fields/tanstack-table-filter-text.tsx +76 -0
  190. package/src/custom/tanstack-table/fields/tanstack-table-pagination.tsx +136 -0
  191. package/src/custom/tanstack-table/fields/tanstack-table-plain-table.tsx +142 -0
  192. package/src/custom/tanstack-table/fields/tanstack-table-row-actions-confirmation.tsx +77 -0
  193. package/src/custom/tanstack-table/fields/tanstack-table-row-actions-custom-dialog.tsx +87 -0
  194. package/src/custom/tanstack-table/fields/tanstack-table-row-actions.tsx +151 -0
  195. package/src/custom/tanstack-table/fields/tanstack-table-table-actions-custom-dialog.tsx +88 -0
  196. package/src/custom/tanstack-table/fields/tanstack-table-table-actions-schemaform-dialog.tsx +47 -0
  197. package/src/custom/tanstack-table/fields/tanstack-table-toolbar.tsx +143 -0
  198. package/src/custom/tanstack-table/fields/tanstack-table-view-options.tsx +171 -0
  199. package/src/custom/tanstack-table/index.tsx +244 -0
  200. package/src/custom/tanstack-table/types/index.ts +328 -0
  201. package/src/custom/tanstack-table/utils/cell-with-actions.tsx +21 -0
  202. package/src/custom/tanstack-table/utils/column-names.ts +26 -0
  203. package/src/custom/tanstack-table/utils/columns-by-row-data.tsx +312 -0
  204. package/src/custom/tanstack-table/utils/editable-columns-by-row-data.tsx +219 -0
  205. package/src/custom/tanstack-table/utils/faceted-boolean-options.tsx +22 -0
  206. package/src/custom/tanstack-table/utils/index.tsx +10 -0
  207. package/src/custom/tanstack-table/utils/pinning-styles.ts +57 -0
  208. package/src/custom/tanstack-table/utils/table.tsx +83 -0
  209. package/src/custom/tanstack-table/utils/test-conditions.ts +17 -0
  210. package/src/custom/timeline.tsx +208 -0
  211. package/src/custom/tree.tsx +200 -0
  212. package/src/custom/tscanify/browser.ts +66 -0
  213. package/src/custom/tscanify/index.ts +51 -0
  214. package/src/custom/tscanify/tscanify-browser.ts +522 -0
  215. package/src/custom/tscanify/tscanify.ts +262 -0
  216. package/src/custom/tscanify/types.ts +22 -0
  217. package/src/custom/webcam.tsx +737 -0
  218. package/src/hooks/.gitkeep +0 -0
  219. package/src/hooks/use-callback-ref.ts +27 -0
  220. package/src/hooks/use-controllable-state.ts +67 -0
  221. package/src/hooks/use-debounce.ts +19 -0
  222. package/src/hooks/use-is-visible.ts +23 -0
  223. package/src/hooks/use-media-query.ts +21 -0
  224. package/src/hooks/use-mobile.ts +21 -0
  225. package/src/hooks/use-on-window-resize.ts +15 -0
  226. package/src/hooks/use-scroll.tsx +22 -0
  227. package/src/lib/utils.ts +61 -0
  228. package/src/lib/zod.ts +2 -0
  229. package/src/styles/core.css +57 -0
  230. package/src/styles/globals.css +130 -0
  231. package/src/test/email-input.test.tsx +217 -0
  232. package/src/test/password-input.test.tsx +92 -0
  233. package/src/test/select-tabs.test.tsx +302 -0
  234. package/src/test/selectable.test.tsx +1093 -0
  235. package/tsconfig.json +13 -0
  236. package/tsconfig.lint.json +8 -0
@@ -0,0 +1,1093 @@
1
+ import { act, render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { Selectable } from "../custom/selectable";
4
+
5
+ interface TestOption {
6
+ id: string;
7
+ name: string;
8
+ group?: string;
9
+ }
10
+
11
+ describe("Selectable Component", () => {
12
+ const mockOptions: TestOption[] = [
13
+ { id: "1", name: "Option 1", group: "Group A" },
14
+ { id: "2", name: "Option 2", group: "Group A" },
15
+ { id: "3", name: "Option 3", group: "Group B" },
16
+ { id: "4", name: "Option 4", group: "Group B" },
17
+ ];
18
+
19
+ const defaultProps = {
20
+ options: mockOptions,
21
+ getKey: (option: TestOption) => option.id,
22
+ getLabel: (option: TestOption) => option.name,
23
+ onChange: jest.fn(),
24
+ };
25
+
26
+ beforeEach(() => {
27
+ jest.clearAllMocks();
28
+ });
29
+
30
+ describe("Rendering", () => {
31
+ it("should render with default placeholder text", () => {
32
+ render(<Selectable {...defaultProps} />);
33
+ expect(screen.getByText("Make a choice...")).toBeInTheDocument();
34
+ });
35
+
36
+ it("should render with custom placeholder text", () => {
37
+ render(
38
+ <Selectable {...defaultProps} makeAChoiceText="Select an option" />
39
+ );
40
+ expect(screen.getByText("Select an option")).toBeInTheDocument();
41
+ });
42
+
43
+ it("should render with custom className", () => {
44
+ const { container } = render(
45
+ <Selectable {...defaultProps} className="custom-class" />
46
+ );
47
+ const trigger = container.querySelector(".custom-class");
48
+ expect(trigger).toBeInTheDocument();
49
+ });
50
+
51
+ it("should render with default values", () => {
52
+ render(<Selectable {...defaultProps} defaultValue={[mockOptions[0]!]} />);
53
+ expect(screen.getByText("Option 1")).toBeInTheDocument();
54
+ });
55
+
56
+ it("should render with custom id", () => {
57
+ render(<Selectable {...defaultProps} id="custom-select" />);
58
+ const trigger = screen.getByRole("button");
59
+ expect(trigger).toHaveAttribute("id", "custom-select");
60
+ });
61
+ });
62
+
63
+ describe("Single Selection Mode", () => {
64
+ it("should select a single option", async () => {
65
+ const user = userEvent.setup();
66
+ const onChange = jest.fn();
67
+
68
+ render(
69
+ <Selectable {...defaultProps} singular={true} onChange={onChange} />
70
+ );
71
+
72
+ // Open popover
73
+ const trigger = screen.getByRole("button");
74
+ await user.click(trigger);
75
+
76
+ // Select an option
77
+ const option1 = screen.getByText("Option 1");
78
+ await user.click(option1);
79
+
80
+ expect(onChange).toHaveBeenCalledWith([mockOptions[0]]);
81
+ });
82
+
83
+ it("should replace selection when selecting another option in singular mode", async () => {
84
+ const user = userEvent.setup();
85
+ const onChange = jest.fn();
86
+
87
+ render(
88
+ <Selectable
89
+ {...defaultProps}
90
+ singular={true}
91
+ onChange={onChange}
92
+ defaultValue={[mockOptions[0]!]}
93
+ />
94
+ );
95
+
96
+ // Open popover
97
+ const trigger = screen.getByRole("button");
98
+ await user.click(trigger);
99
+
100
+ // Select another option
101
+ const option2 = screen.getByText("Option 2");
102
+ await user.click(option2);
103
+
104
+ expect(onChange).toHaveBeenCalledWith([mockOptions[1]]);
105
+ });
106
+
107
+ it("should close popover after selection in singular mode", async () => {
108
+ const user = userEvent.setup();
109
+
110
+ render(<Selectable {...defaultProps} singular={true} />);
111
+
112
+ const trigger = screen.getByRole("button");
113
+ await user.click(trigger);
114
+
115
+ const option1 = screen.getByText("Option 1");
116
+ await user.click(option1);
117
+
118
+ // Popover should close
119
+ await waitFor(() => {
120
+ expect(
121
+ screen.queryByPlaceholderText("Search...")
122
+ ).not.toBeInTheDocument();
123
+ });
124
+ });
125
+ });
126
+
127
+ describe("Multiple Selection Mode", () => {
128
+ it("should select multiple options", async () => {
129
+ const user = userEvent.setup();
130
+ const onChange = jest.fn();
131
+
132
+ render(<Selectable {...defaultProps} onChange={onChange} />);
133
+
134
+ const trigger = screen.getByRole("button");
135
+ await user.click(trigger);
136
+
137
+ // Select first option
138
+ const option1 = screen.getByText("Option 1");
139
+ await user.click(option1);
140
+
141
+ expect(onChange).toHaveBeenCalledWith([mockOptions[0]]);
142
+
143
+ // Select second option
144
+ const option2 = screen.getByText("Option 2");
145
+ await user.click(option2);
146
+
147
+ expect(onChange).toHaveBeenCalledWith([mockOptions[0], mockOptions[1]]);
148
+ });
149
+
150
+ it("should deselect option by clicking on badge", async () => {
151
+ const user = userEvent.setup();
152
+ const onChange = jest.fn();
153
+
154
+ render(
155
+ <Selectable
156
+ {...defaultProps}
157
+ onChange={onChange}
158
+ defaultValue={[mockOptions[0]!, mockOptions[1]!]}
159
+ />
160
+ );
161
+
162
+ // Find and click the badge to remove
163
+ const badges = screen.getAllByText("Option 1");
164
+ await user.click(badges[0]!);
165
+
166
+ expect(onChange).toHaveBeenCalledWith([mockOptions[1]]);
167
+ });
168
+
169
+ it("should deselect option from selected list", async () => {
170
+ const user = userEvent.setup();
171
+ const onChange = jest.fn();
172
+
173
+ render(
174
+ <Selectable
175
+ {...defaultProps}
176
+ onChange={onChange}
177
+ defaultValue={[mockOptions[0]!]}
178
+ />
179
+ );
180
+
181
+ const trigger = screen.getByRole("button");
182
+ await user.click(trigger);
183
+
184
+ // Expand selected section
185
+ const selectedHeader = screen.getByText("Selected");
186
+ await user.click(selectedHeader);
187
+
188
+ // Click on selected option to deselect
189
+ const selectedOption = screen.getByRole("option", { name: /option 1/i });
190
+ await user.click(selectedOption);
191
+
192
+ expect(onChange).toHaveBeenCalledWith([]);
193
+ });
194
+
195
+ it("should keep popover open after selection in multiple mode", async () => {
196
+ const user = userEvent.setup();
197
+
198
+ render(<Selectable {...defaultProps} />);
199
+
200
+ const trigger = screen.getByRole("button");
201
+ await user.click(trigger);
202
+
203
+ const option1 = screen.getByText("Option 1");
204
+ await user.click(option1);
205
+
206
+ // Popover should remain open
207
+ expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
208
+ });
209
+ });
210
+
211
+ describe("Search Functionality", () => {
212
+ it("should filter options based on search input", async () => {
213
+ const user = userEvent.setup();
214
+
215
+ render(<Selectable {...defaultProps} />);
216
+
217
+ const trigger = screen.getByRole("button");
218
+ await user.click(trigger);
219
+
220
+ const searchInput = screen.getByPlaceholderText("Search...");
221
+ await user.type(searchInput, "Option 1");
222
+
223
+ await waitFor(() => {
224
+ expect(screen.getByText("Option 1")).toBeInTheDocument();
225
+ expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
226
+ });
227
+ });
228
+
229
+ it("should show no results message when search returns nothing", async () => {
230
+ const user = userEvent.setup();
231
+
232
+ render(<Selectable {...defaultProps} noResult="No items found" />);
233
+
234
+ const trigger = screen.getByRole("button");
235
+ await user.click(trigger);
236
+
237
+ const searchInput = screen.getByPlaceholderText("Search...");
238
+ await user.type(searchInput, "NonExistent");
239
+
240
+ await waitFor(() => {
241
+ expect(screen.getByText("No items found")).toBeInTheDocument();
242
+ });
243
+ });
244
+
245
+ it("should use custom search placeholder text", async () => {
246
+ const user = userEvent.setup();
247
+
248
+ render(
249
+ <Selectable
250
+ {...defaultProps}
251
+ searchPlaceholderText="Type to search..."
252
+ />
253
+ );
254
+
255
+ const trigger = screen.getByRole("button");
256
+ await user.click(trigger);
257
+
258
+ expect(
259
+ screen.getByPlaceholderText("Type to search...")
260
+ ).toBeInTheDocument();
261
+ });
262
+
263
+ it("should clear search when popover closes", async () => {
264
+ const user = userEvent.setup();
265
+
266
+ render(<Selectable {...defaultProps} />);
267
+
268
+ const trigger = screen.getByRole("button");
269
+ await user.click(trigger);
270
+
271
+ const searchInput = screen.getByPlaceholderText("Search...");
272
+ await user.type(searchInput, "Option 1");
273
+
274
+ // Close popover
275
+ await user.click(trigger);
276
+
277
+ // Reopen popover
278
+ await user.click(trigger);
279
+
280
+ // Search should be cleared
281
+ const newSearchInput = screen.getByPlaceholderText("Search...");
282
+ expect(newSearchInput).toHaveValue("");
283
+ });
284
+ });
285
+
286
+ describe("Async Search", () => {
287
+ it("should call onSearch with debounced value", async () => {
288
+ const user = userEvent.setup();
289
+ const onSearch = jest.fn().mockResolvedValue([mockOptions[0]]);
290
+
291
+ render(<Selectable {...defaultProps} options={[]} onSearch={onSearch} />);
292
+
293
+ const trigger = screen.getByRole("button");
294
+ await user.click(trigger);
295
+
296
+ const searchInput = screen.getByPlaceholderText("Search...");
297
+ await user.type(searchInput, "test");
298
+
299
+ // Wait for debounce
300
+ await waitFor(
301
+ () => {
302
+ expect(onSearch).toHaveBeenCalledWith("test");
303
+ },
304
+ { timeout: 1000 }
305
+ );
306
+ });
307
+
308
+ it("should show loading state during async search", async () => {
309
+ const user = userEvent.setup();
310
+ const onSearch = jest.fn().mockImplementation(
311
+ () =>
312
+ new Promise((resolve) => {
313
+ setTimeout(() => resolve([mockOptions[0]]), 100);
314
+ })
315
+ );
316
+
317
+ render(<Selectable {...defaultProps} options={[]} onSearch={onSearch} />);
318
+
319
+ const trigger = screen.getByRole("button");
320
+ await user.click(trigger);
321
+
322
+ const searchInput = screen.getByPlaceholderText("Search...");
323
+ await user.type(searchInput, "test");
324
+
325
+ // Should show skeleton loaders
326
+ await waitFor(() => {
327
+ const skeletons = document.querySelectorAll(".animate-pulse");
328
+ expect(skeletons.length).toBeGreaterThan(0);
329
+ });
330
+ });
331
+
332
+ it("should display search results after async search completes", async () => {
333
+ const user = userEvent.setup();
334
+ const searchResults = [mockOptions[0]!, mockOptions[1]!];
335
+ const onSearch = jest.fn().mockResolvedValue(searchResults);
336
+
337
+ render(<Selectable {...defaultProps} options={[]} onSearch={onSearch} />);
338
+
339
+ const trigger = screen.getByRole("button");
340
+ await user.click(trigger);
341
+
342
+ const searchInput = screen.getByPlaceholderText("Search...");
343
+ await user.type(searchInput, "test");
344
+
345
+ // Wait for debounce and search to complete
346
+ await waitFor(
347
+ () => {
348
+ expect(onSearch).toHaveBeenCalledWith("test");
349
+ },
350
+ { timeout: 1500 }
351
+ );
352
+
353
+ // Verify that results were returned from the API
354
+ expect(onSearch).toHaveReturnedWith(Promise.resolve(searchResults));
355
+ });
356
+
357
+ it('should show "Type to search..." when options are empty and onSearch is provided', async () => {
358
+ const user = userEvent.setup();
359
+ const onSearch = jest.fn().mockResolvedValue([]);
360
+
361
+ render(
362
+ <Selectable
363
+ {...defaultProps}
364
+ options={[]}
365
+ onSearch={onSearch}
366
+ typeToSearchText="Type to search..."
367
+ />
368
+ );
369
+
370
+ const trigger = screen.getByRole("button");
371
+ await user.click(trigger);
372
+
373
+ expect(screen.getByText("Type to search...")).toBeInTheDocument();
374
+ });
375
+
376
+ it("should show custom typeToSearchText when provided", async () => {
377
+ const user = userEvent.setup();
378
+ const onSearch = jest.fn().mockResolvedValue([]);
379
+
380
+ render(
381
+ <Selectable
382
+ {...defaultProps}
383
+ options={[]}
384
+ onSearch={onSearch}
385
+ typeToSearchText="Start typing to find items..."
386
+ />
387
+ );
388
+
389
+ const trigger = screen.getByRole("button");
390
+ await user.click(trigger);
391
+
392
+ expect(
393
+ screen.getByText("Start typing to find items...")
394
+ ).toBeInTheDocument();
395
+ });
396
+
397
+ it("should show noResult message after searching when no results found", async () => {
398
+ const user = userEvent.setup();
399
+ const onSearch = jest.fn().mockResolvedValue([]);
400
+
401
+ render(
402
+ <Selectable
403
+ {...defaultProps}
404
+ options={[]}
405
+ onSearch={onSearch}
406
+ typeToSearchText="Type to search..."
407
+ noResult="No results found"
408
+ />
409
+ );
410
+
411
+ const trigger = screen.getByRole("button");
412
+ await user.click(trigger);
413
+
414
+ // Initially should show "Type to search..."
415
+ expect(screen.getByText("Type to search...")).toBeInTheDocument();
416
+
417
+ // After searching, should show "No results found"
418
+ const searchInput = screen.getByPlaceholderText("Search...");
419
+ await user.type(searchInput, "test");
420
+
421
+ await waitFor(
422
+ () => {
423
+ expect(onSearch).toHaveBeenCalledWith("test");
424
+ },
425
+ { timeout: 1500 }
426
+ );
427
+
428
+ await waitFor(
429
+ () => {
430
+ expect(screen.getByText("No results found")).toBeInTheDocument();
431
+ },
432
+ { timeout: 500 }
433
+ );
434
+ });
435
+
436
+ it("should not show typeToSearchText when options are provided", async () => {
437
+ const user = userEvent.setup();
438
+ const onSearch = jest.fn().mockResolvedValue([]);
439
+
440
+ render(
441
+ <Selectable
442
+ {...defaultProps}
443
+ options={mockOptions}
444
+ onSearch={onSearch}
445
+ typeToSearchText="Type to search..."
446
+ />
447
+ );
448
+
449
+ const trigger = screen.getByRole("button");
450
+ await user.click(trigger);
451
+
452
+ expect(screen.queryByText("Type to search...")).not.toBeInTheDocument();
453
+ expect(screen.getByText("Option 1")).toBeInTheDocument();
454
+ });
455
+ });
456
+
457
+ describe("Grouping", () => {
458
+ it("should group options when getGroup is provided", async () => {
459
+ const user = userEvent.setup();
460
+
461
+ render(
462
+ <Selectable
463
+ {...defaultProps}
464
+ getGroup={(option) => option.group || ""}
465
+ />
466
+ );
467
+
468
+ const trigger = screen.getByRole("button");
469
+ await user.click(trigger);
470
+
471
+ expect(screen.getByText("Group A")).toBeInTheDocument();
472
+ expect(screen.getByText("Group B")).toBeInTheDocument();
473
+ });
474
+
475
+ it("should sort groups alphabetically", async () => {
476
+ const user = userEvent.setup();
477
+ const options: TestOption[] = [
478
+ { id: "1", name: "Option 1", group: "Zebra" },
479
+ { id: "2", name: "Option 2", group: "Apple" },
480
+ ];
481
+
482
+ render(
483
+ <Selectable
484
+ {...defaultProps}
485
+ options={options}
486
+ getGroup={(option) => option.group || ""}
487
+ />
488
+ );
489
+
490
+ const trigger = screen.getByRole("button");
491
+ await user.click(trigger);
492
+
493
+ // Check that Apple group comes before Zebra
494
+ const groupHeadings = screen.getAllByRole("group");
495
+ const appleGroup = groupHeadings.find(
496
+ (g) =>
497
+ g.getAttribute("aria-labelledby")?.includes("Apple") ||
498
+ g.textContent?.includes("Option 2")
499
+ );
500
+ expect(appleGroup).toBeInTheDocument();
501
+ });
502
+ });
503
+
504
+ describe("Custom Rendering", () => {
505
+ it("should use custom renderOption when provided", async () => {
506
+ const user = userEvent.setup();
507
+ const renderOption = jest.fn((option: TestOption) => (
508
+ <div data-testid={`custom-${option.id}`}>{option.name} (Custom)</div>
509
+ ));
510
+
511
+ render(
512
+ <Selectable
513
+ {...defaultProps}
514
+ defaultValue={[mockOptions[0]!]}
515
+ renderOption={renderOption}
516
+ />
517
+ );
518
+
519
+ const trigger = screen.getByRole("button");
520
+ await user.click(trigger);
521
+
522
+ // Expand selected section
523
+ const selectedHeader = screen.getByText("Selected");
524
+ await user.click(selectedHeader);
525
+
526
+ expect(screen.getByTestId("custom-1")).toBeInTheDocument();
527
+ expect(screen.getByText("Option 1 (Custom)")).toBeInTheDocument();
528
+ });
529
+
530
+ it("should use custom renderTrigger when provided", () => {
531
+ const renderTrigger = jest.fn(({ children, disabled }) => (
532
+ <div data-testid="custom-trigger" data-disabled={disabled}>
533
+ {children}
534
+ </div>
535
+ ));
536
+
537
+ render(<Selectable {...defaultProps} renderTrigger={renderTrigger} />);
538
+
539
+ expect(screen.getByTestId("custom-trigger")).toBeInTheDocument();
540
+ expect(renderTrigger).toHaveBeenCalled();
541
+
542
+ // Check the last call
543
+ const lastCall =
544
+ renderTrigger.mock.calls[renderTrigger.mock.calls.length - 1];
545
+ expect(lastCall?.[0]).toMatchObject({
546
+ disabled: false,
547
+ });
548
+ });
549
+
550
+ it("should pass disabled prop to renderTrigger", () => {
551
+ const renderTrigger = jest.fn(({ children, disabled }) => (
552
+ <div data-testid="custom-trigger" data-disabled={disabled}>
553
+ {children}
554
+ </div>
555
+ ));
556
+
557
+ render(
558
+ <Selectable
559
+ {...defaultProps}
560
+ renderTrigger={renderTrigger}
561
+ disabled={true}
562
+ />
563
+ );
564
+
565
+ expect(screen.getByTestId("custom-trigger")).toBeInTheDocument();
566
+
567
+ // Check the last call
568
+ const lastCall =
569
+ renderTrigger.mock.calls[renderTrigger.mock.calls.length - 1];
570
+ expect(lastCall?.[0]).toMatchObject({
571
+ disabled: true,
572
+ });
573
+
574
+ const trigger = screen.getByTestId("custom-trigger");
575
+ expect(trigger).toHaveAttribute("data-disabled", "true");
576
+ });
577
+ });
578
+
579
+ describe("Single Line Mode", () => {
580
+ it("should display selected options in single line", () => {
581
+ render(
582
+ <Selectable
583
+ {...defaultProps}
584
+ singleLine={true}
585
+ defaultValue={[mockOptions[0]!, mockOptions[1]!]}
586
+ />
587
+ );
588
+
589
+ const selectedText = screen.getByText("Option 1, Option 2");
590
+ expect(selectedText).toBeInTheDocument();
591
+ expect(selectedText.className).toContain("truncate");
592
+ });
593
+
594
+ it("should not show badges in single line mode", () => {
595
+ const { container } = render(
596
+ <Selectable
597
+ {...defaultProps}
598
+ singleLine={true}
599
+ defaultValue={[mockOptions[0]!]}
600
+ />
601
+ );
602
+
603
+ const badges = container.querySelectorAll('[class*="badge"]');
604
+ expect(badges.length).toBe(0);
605
+ });
606
+ });
607
+
608
+ describe("Selected Options Section", () => {
609
+ it("should show selected section when options are selected", async () => {
610
+ const user = userEvent.setup();
611
+
612
+ render(<Selectable {...defaultProps} defaultValue={[mockOptions[0]!]} />);
613
+
614
+ const trigger = screen.getByRole("button");
615
+ await user.click(trigger);
616
+
617
+ expect(screen.getByText("Selected")).toBeInTheDocument();
618
+ });
619
+
620
+ it("should use custom selectedText", async () => {
621
+ const user = userEvent.setup();
622
+
623
+ render(
624
+ <Selectable
625
+ {...defaultProps}
626
+ selectedText="Chosen Items"
627
+ defaultValue={[mockOptions[0]!]}
628
+ />
629
+ );
630
+
631
+ const trigger = screen.getByRole("button");
632
+ await user.click(trigger);
633
+
634
+ expect(screen.getByText("Chosen Items")).toBeInTheDocument();
635
+ });
636
+
637
+ it("should toggle selected section on header click", async () => {
638
+ const user = userEvent.setup();
639
+
640
+ render(<Selectable {...defaultProps} defaultValue={[mockOptions[0]!]} />);
641
+
642
+ const trigger = screen.getByRole("button");
643
+ await user.click(trigger);
644
+
645
+ const selectedHeader = screen.getByText("Selected");
646
+
647
+ // Check if collapsible section exists
648
+ expect(selectedHeader).toBeInTheDocument();
649
+
650
+ // Click to toggle
651
+ await user.click(selectedHeader);
652
+
653
+ // Wait for animation
654
+ await waitFor(() => {
655
+ const collapsible = selectedHeader.closest(
656
+ '[data-slot="collapsible-trigger"]'
657
+ );
658
+ expect(collapsible).toHaveAttribute("data-state");
659
+ });
660
+ });
661
+
662
+ it("should not show selected section when no options are selected", async () => {
663
+ const user = userEvent.setup();
664
+
665
+ render(<Selectable {...defaultProps} />);
666
+
667
+ const trigger = screen.getByRole("button");
668
+ await user.click(trigger);
669
+
670
+ expect(screen.queryByText("Selected")).not.toBeInTheDocument();
671
+ });
672
+ });
673
+
674
+ describe("Option Filtering", () => {
675
+ it("should not show selected options in selectable list", async () => {
676
+ const user = userEvent.setup();
677
+
678
+ render(<Selectable {...defaultProps} defaultValue={[mockOptions[0]!]} />);
679
+
680
+ const trigger = screen.getByRole("button");
681
+ await user.click(trigger);
682
+
683
+ // Option 1 should only appear in selected section
684
+ const option1Elements = screen.getAllByText("Option 1");
685
+ expect(option1Elements.length).toBe(1); // Only in selected section
686
+
687
+ // Other options should be available
688
+ expect(screen.getByText("Option 2")).toBeInTheDocument();
689
+ expect(screen.getByText("Option 3")).toBeInTheDocument();
690
+ });
691
+ });
692
+
693
+ describe("Edge Cases", () => {
694
+ it("should handle empty options array", async () => {
695
+ const user = userEvent.setup();
696
+
697
+ render(
698
+ <Selectable
699
+ {...defaultProps}
700
+ options={[]}
701
+ noResult="No options available"
702
+ />
703
+ );
704
+
705
+ const trigger = screen.getByRole("button");
706
+ await user.click(trigger);
707
+
708
+ expect(screen.getByText("No options available")).toBeInTheDocument();
709
+ });
710
+
711
+ it("should handle undefined options", async () => {
712
+ const user = userEvent.setup();
713
+
714
+ render(
715
+ <Selectable
716
+ {...defaultProps}
717
+ options={undefined}
718
+ noResult="No options"
719
+ />
720
+ );
721
+
722
+ const trigger = screen.getByRole("button");
723
+ await user.click(trigger);
724
+
725
+ expect(screen.getByText("No options")).toBeInTheDocument();
726
+ });
727
+
728
+ it("should handle rapid selection changes", async () => {
729
+ const user = userEvent.setup();
730
+ const onChange = jest.fn();
731
+
732
+ render(<Selectable {...defaultProps} onChange={onChange} />);
733
+
734
+ const trigger = screen.getByRole("button");
735
+ await user.click(trigger);
736
+
737
+ // Rapidly select multiple options
738
+ await user.click(screen.getByText("Option 1"));
739
+ await user.click(screen.getByText("Option 2"));
740
+ await user.click(screen.getByText("Option 3"));
741
+
742
+ expect(onChange).toHaveBeenCalledTimes(3);
743
+ });
744
+
745
+ it("should handle option key extraction correctly", async () => {
746
+ const user = userEvent.setup();
747
+ const customOptions = [
748
+ { customId: "unique-1", label: "First" },
749
+ { customId: "unique-2", label: "Second" },
750
+ ];
751
+
752
+ render(
753
+ <Selectable
754
+ options={customOptions}
755
+ getKey={(opt) => opt.customId}
756
+ getLabel={(opt) => opt.label}
757
+ onChange={jest.fn()}
758
+ />
759
+ );
760
+
761
+ const trigger = screen.getByRole("button");
762
+ await user.click(trigger);
763
+
764
+ expect(screen.getByText("First")).toBeInTheDocument();
765
+ expect(screen.getByText("Second")).toBeInTheDocument();
766
+ });
767
+ });
768
+
769
+ describe("Accessibility", () => {
770
+ it("should have proper ARIA attributes", async () => {
771
+ const user = userEvent.setup();
772
+
773
+ render(<Selectable {...defaultProps} />);
774
+
775
+ const trigger = screen.getByRole("button");
776
+ expect(trigger).toBeInTheDocument();
777
+
778
+ await user.click(trigger);
779
+
780
+ const searchInput = screen.getByPlaceholderText("Search...");
781
+ expect(searchInput).toBeInTheDocument();
782
+ });
783
+
784
+ it("should support keyboard navigation", async () => {
785
+ const user = userEvent.setup();
786
+
787
+ render(<Selectable {...defaultProps} />);
788
+
789
+ const trigger = screen.getByRole("button");
790
+
791
+ // Open with Enter key
792
+ trigger.focus();
793
+ await user.keyboard("{Enter}");
794
+
795
+ expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
796
+ });
797
+
798
+ it("should have id attribute on no-result element", async () => {
799
+ const user = userEvent.setup();
800
+
801
+ render(
802
+ <Selectable {...defaultProps} options={[]} noResult="No results" />
803
+ );
804
+
805
+ const trigger = screen.getByRole("button");
806
+ await user.click(trigger);
807
+
808
+ const noResultElement = document.getElementById("no-result-text");
809
+ expect(noResultElement).toBeInTheDocument();
810
+ expect(noResultElement).toHaveTextContent("No results");
811
+ });
812
+ });
813
+
814
+ describe("Disabled State", () => {
815
+ it("should render as disabled when disabled prop is true", () => {
816
+ render(<Selectable {...defaultProps} disabled={true} />);
817
+
818
+ const trigger = screen.getByRole("button");
819
+ expect(trigger).toBeDisabled();
820
+ });
821
+
822
+ it("should not open popover when disabled", async () => {
823
+ const user = userEvent.setup();
824
+
825
+ render(<Selectable {...defaultProps} disabled={true} />);
826
+
827
+ const trigger = screen.getByRole("button");
828
+ await user.click(trigger);
829
+
830
+ // Popover should not open
831
+ expect(
832
+ screen.queryByPlaceholderText("Search...")
833
+ ).not.toBeInTheDocument();
834
+ });
835
+
836
+ it("should not allow badge removal when disabled", async () => {
837
+ const user = userEvent.setup();
838
+ const onChange = jest.fn();
839
+
840
+ render(
841
+ <Selectable
842
+ {...defaultProps}
843
+ disabled={true}
844
+ defaultValue={[mockOptions[0]!]}
845
+ onChange={onChange}
846
+ />
847
+ );
848
+
849
+ // Try to click on badge to remove
850
+ const badges = screen.getAllByText("Option 1");
851
+ await user.click(badges[0]!);
852
+
853
+ // onChange should not be called
854
+ expect(onChange).not.toHaveBeenCalled();
855
+ });
856
+
857
+ it("should not trigger onChange when disabled in single mode", async () => {
858
+ const user = userEvent.setup();
859
+ const onChange = jest.fn();
860
+
861
+ render(
862
+ <Selectable
863
+ {...defaultProps}
864
+ disabled={true}
865
+ singular={true}
866
+ onChange={onChange}
867
+ />
868
+ );
869
+
870
+ const trigger = screen.getByRole("button");
871
+ await user.click(trigger);
872
+
873
+ // Popover should not open, so onChange should not be called
874
+ expect(onChange).not.toHaveBeenCalled();
875
+ });
876
+
877
+ it("should have disabled styling", () => {
878
+ const { container } = render(
879
+ <Selectable {...defaultProps} disabled={true} />
880
+ );
881
+
882
+ const trigger = screen.getByRole("button");
883
+ expect(trigger).toHaveClass("disabled:pointer-events-none");
884
+ expect(trigger).toHaveClass("disabled:opacity-50");
885
+ });
886
+
887
+ it("should allow enabling after being disabled", async () => {
888
+ const user = userEvent.setup();
889
+ const { rerender } = render(
890
+ <Selectable {...defaultProps} disabled={true} />
891
+ );
892
+
893
+ const trigger = screen.getByRole("button");
894
+ expect(trigger).toBeDisabled();
895
+
896
+ // Re-render with disabled=false
897
+ rerender(<Selectable {...defaultProps} disabled={false} />);
898
+
899
+ expect(trigger).not.toBeDisabled();
900
+
901
+ // Should be able to open popover
902
+ await user.click(trigger);
903
+ expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
904
+ });
905
+ });
906
+
907
+ describe("Disabled Options", () => {
908
+ it("should mark options as disabled when getDisabled returns true", async () => {
909
+ const user = userEvent.setup();
910
+
911
+ render(
912
+ <Selectable
913
+ {...defaultProps}
914
+ getDisabled={(option) => option.id === "2"}
915
+ />
916
+ );
917
+
918
+ const trigger = screen.getByRole("button");
919
+ await user.click(trigger);
920
+
921
+ const option1 = screen.getByRole("option", { name: "Option 1" });
922
+ const option2 = screen.getByRole("option", { name: "Option 2" });
923
+
924
+ expect(option1).not.toHaveAttribute("aria-disabled", "true");
925
+ expect(option2).toHaveAttribute("aria-disabled", "true");
926
+ });
927
+
928
+ it("should not allow selecting disabled options", async () => {
929
+ const user = userEvent.setup();
930
+ const onChange = jest.fn();
931
+
932
+ render(
933
+ <Selectable
934
+ {...defaultProps}
935
+ getDisabled={(option) => option.id === "2"}
936
+ onChange={onChange}
937
+ />
938
+ );
939
+
940
+ const trigger = screen.getByRole("button");
941
+ await user.click(trigger);
942
+
943
+ const option2 = screen.getByRole("option", { name: "Option 2" });
944
+ await user.click(option2);
945
+
946
+ // onChange should not be called for disabled option
947
+ expect(onChange).not.toHaveBeenCalled();
948
+ });
949
+
950
+ it("should allow selecting non-disabled options", async () => {
951
+ const user = userEvent.setup();
952
+ const onChange = jest.fn();
953
+
954
+ render(
955
+ <Selectable
956
+ {...defaultProps}
957
+ getDisabled={(option) => option.id === "2"}
958
+ onChange={onChange}
959
+ />
960
+ );
961
+
962
+ const trigger = screen.getByRole("button");
963
+ await user.click(trigger);
964
+
965
+ const option1 = screen.getByRole("option", { name: "Option 1" });
966
+ await user.click(option1);
967
+
968
+ // onChange should be called for enabled option
969
+ expect(onChange).toHaveBeenCalledWith([mockOptions[0]]);
970
+ });
971
+
972
+ it("should not allow deselecting disabled options from selected list", async () => {
973
+ const user = userEvent.setup();
974
+ const onChange = jest.fn();
975
+
976
+ render(
977
+ <Selectable
978
+ {...defaultProps}
979
+ defaultValue={[mockOptions[0]!, mockOptions[1]!]}
980
+ getDisabled={(option) => option.id === "1"}
981
+ onChange={onChange}
982
+ />
983
+ );
984
+
985
+ const trigger = screen.getByRole("button");
986
+ await user.click(trigger);
987
+
988
+ // Expand selected section
989
+ const selectedHeader = screen.getByText("Selected");
990
+ await user.click(selectedHeader);
991
+
992
+ const selectedOption1 = screen.getAllByRole("option", {
993
+ name: /option 1/i,
994
+ })[0];
995
+ await user.click(selectedOption1!);
996
+
997
+ // onChange should not be called when trying to deselect disabled option
998
+ expect(onChange).not.toHaveBeenCalled();
999
+ });
1000
+
1001
+ it("should allow deselecting non-disabled options from selected list", async () => {
1002
+ const user = userEvent.setup();
1003
+ const onChange = jest.fn();
1004
+
1005
+ render(
1006
+ <Selectable
1007
+ {...defaultProps}
1008
+ defaultValue={[mockOptions[0]!, mockOptions[1]!]}
1009
+ getDisabled={(option) => option.id === "1"}
1010
+ onChange={onChange}
1011
+ />
1012
+ );
1013
+
1014
+ const trigger = screen.getByRole("button");
1015
+ await user.click(trigger);
1016
+
1017
+ // Expand selected section
1018
+ const selectedHeader = screen.getByText("Selected");
1019
+ await user.click(selectedHeader);
1020
+
1021
+ const selectedOption2 = screen.getAllByRole("option", {
1022
+ name: /option 2/i,
1023
+ })[0];
1024
+ await user.click(selectedOption2!);
1025
+
1026
+ // onChange should be called when deselecting non-disabled option
1027
+ expect(onChange).toHaveBeenCalledWith([mockOptions[0]]);
1028
+ });
1029
+
1030
+ it("should work without getDisabled prop", async () => {
1031
+ const user = userEvent.setup();
1032
+ const onChange = jest.fn();
1033
+
1034
+ render(<Selectable {...defaultProps} onChange={onChange} />);
1035
+
1036
+ const trigger = screen.getByRole("button");
1037
+ await user.click(trigger);
1038
+
1039
+ const option1 = screen.getByRole("option", { name: "Option 1" });
1040
+ await user.click(option1);
1041
+
1042
+ // All options should be selectable when getDisabled is not provided
1043
+ expect(onChange).toHaveBeenCalledWith([mockOptions[0]]);
1044
+ });
1045
+
1046
+ it("should handle multiple disabled options", async () => {
1047
+ const user = userEvent.setup();
1048
+
1049
+ render(
1050
+ <Selectable
1051
+ {...defaultProps}
1052
+ getDisabled={(option) => option.id === "2" || option.id === "4"}
1053
+ />
1054
+ );
1055
+
1056
+ const trigger = screen.getByRole("button");
1057
+ await user.click(trigger);
1058
+
1059
+ const option1 = screen.getByRole("option", { name: "Option 1" });
1060
+ const option2 = screen.getByRole("option", { name: "Option 2" });
1061
+ const option3 = screen.getByRole("option", { name: "Option 3" });
1062
+ const option4 = screen.getByRole("option", { name: "Option 4" });
1063
+
1064
+ expect(option1).not.toHaveAttribute("aria-disabled", "true");
1065
+ expect(option2).toHaveAttribute("aria-disabled", "true");
1066
+ expect(option3).not.toHaveAttribute("aria-disabled", "true");
1067
+ expect(option4).toHaveAttribute("aria-disabled", "true");
1068
+ });
1069
+
1070
+ it("should prevent disabled option selection in singular mode", async () => {
1071
+ const user = userEvent.setup();
1072
+ const onChange = jest.fn();
1073
+
1074
+ render(
1075
+ <Selectable
1076
+ {...defaultProps}
1077
+ singular={true}
1078
+ getDisabled={(option) => option.id === "2"}
1079
+ onChange={onChange}
1080
+ />
1081
+ );
1082
+
1083
+ const trigger = screen.getByRole("button");
1084
+ await user.click(trigger);
1085
+
1086
+ const option2 = screen.getByRole("option", { name: "Option 2" });
1087
+ await user.click(option2);
1088
+
1089
+ // onChange should not be called for disabled option in singular mode
1090
+ expect(onChange).not.toHaveBeenCalled();
1091
+ });
1092
+ });
1093
+ });