@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.
- package/__mocks__/canvas.ts +8 -0
- package/components.json +21 -0
- package/eslint.config.js +4 -0
- package/jest-environment.js +37 -0
- package/jest.config.ts +47 -0
- package/jest.setup.ts +69 -0
- package/package.json +124 -0
- package/postcss.config.mjs +6 -0
- package/src/aria/index.tsx +1 -0
- package/src/aria/number-field.tsx +41 -0
- package/src/components/.gitkeep +0 -0
- package/src/components/accordion.tsx +66 -0
- package/src/components/alert-dialog.tsx +157 -0
- package/src/components/alert.tsx +70 -0
- package/src/components/aspect-ratio.tsx +11 -0
- package/src/components/avatar.tsx +53 -0
- package/src/components/badge.tsx +67 -0
- package/src/components/breadcrumb.tsx +109 -0
- package/src/components/button-group.tsx +83 -0
- package/src/components/button.tsx +68 -0
- package/src/components/calendar.tsx +219 -0
- package/src/components/card.tsx +92 -0
- package/src/components/carousel.tsx +241 -0
- package/src/components/chart.tsx +363 -0
- package/src/components/checkbox.tsx +32 -0
- package/src/components/collapsible.tsx +33 -0
- package/src/components/command.tsx +184 -0
- package/src/components/context-menu.tsx +252 -0
- package/src/components/dialog.tsx +144 -0
- package/src/components/drawer.tsx +135 -0
- package/src/components/dropdown-menu.tsx +258 -0
- package/src/components/empty.tsx +100 -0
- package/src/components/field.tsx +248 -0
- package/src/components/form.tsx +169 -0
- package/src/components/hover-card.tsx +44 -0
- package/src/components/input-group.tsx +170 -0
- package/src/components/input-otp.tsx +77 -0
- package/src/components/input.tsx +21 -0
- package/src/components/item.tsx +193 -0
- package/src/components/kbd.tsx +28 -0
- package/src/components/label.tsx +24 -0
- package/src/components/menubar.tsx +276 -0
- package/src/components/navigation-menu.tsx +168 -0
- package/src/components/pagination.tsx +130 -0
- package/src/components/popover.tsx +88 -0
- package/src/components/progress.tsx +31 -0
- package/src/components/radio-group.tsx +45 -0
- package/src/components/resizable.tsx +56 -0
- package/src/components/scroll-area.tsx +58 -0
- package/src/components/select.tsx +189 -0
- package/src/components/separator.tsx +28 -0
- package/src/components/sheet.tsx +140 -0
- package/src/components/sidebar.tsx +862 -0
- package/src/components/skeleton.tsx +13 -0
- package/src/components/slider.tsx +63 -0
- package/src/components/sonner.tsx +40 -0
- package/src/components/spinner.tsx +16 -0
- package/src/components/stepper.tsx +291 -0
- package/src/components/switch.tsx +31 -0
- package/src/components/table.tsx +133 -0
- package/src/components/tabs.tsx +66 -0
- package/src/components/textarea.tsx +18 -0
- package/src/components/toggle-group.tsx +83 -0
- package/src/components/toggle.tsx +47 -0
- package/src/components/tooltip.tsx +66 -0
- package/src/custom/action-button.tsx +48 -0
- package/src/custom/async-select.tsx +287 -0
- package/src/custom/awesome-not-found.tsx +116 -0
- package/src/custom/charts/area-chart.tsx +147 -0
- package/src/custom/charts/bar-chart.tsx +233 -0
- package/src/custom/charts/chart-card.tsx +103 -0
- package/src/custom/charts/index.tsx +16 -0
- package/src/custom/charts/pie-chart.tsx +168 -0
- package/src/custom/charts/radar-chart.tsx +126 -0
- package/src/custom/checkbox-tree.tsx +100 -0
- package/src/custom/combobox.tsx +296 -0
- package/src/custom/confirm-dialog.tsx +102 -0
- package/src/custom/country-selector.tsx +204 -0
- package/src/custom/date-picker/calendar-rac.tsx +109 -0
- package/src/custom/date-picker/datefield-rac.tsx +84 -0
- package/src/custom/date-picker/index.tsx +273 -0
- package/src/custom/date-picker/types/index.ts +4 -0
- package/src/custom/date-picker/utils/index.ts +42 -0
- package/src/custom/date-picker-old.tsx +50 -0
- package/src/custom/date-tooltip.tsx +98 -0
- package/src/custom/document-scanner/consts.ts +5 -0
- package/src/custom/document-scanner/corner-adjustment/action-buttons.tsx +33 -0
- package/src/custom/document-scanner/corner-adjustment/corner-handle.tsx +43 -0
- package/src/custom/document-scanner/corner-adjustment/hooks/use-corner-drag.ts +85 -0
- package/src/custom/document-scanner/corner-adjustment/index.tsx +125 -0
- package/src/custom/document-scanner/corner-adjustment/types.ts +53 -0
- package/src/custom/document-scanner/corner-adjustment/utils/clip-path.ts +22 -0
- package/src/custom/document-scanner/corner-adjustment/zoom-magnifier.tsx +115 -0
- package/src/custom/document-scanner/hooks/use-document-capture.ts +81 -0
- package/src/custom/document-scanner/hooks/use-document-scanner.ts +80 -0
- package/src/custom/document-scanner/hooks/use-perspective-crop.ts +38 -0
- package/src/custom/document-scanner/index.tsx +255 -0
- package/src/custom/document-scanner/lib.ts +407 -0
- package/src/custom/document-scanner/types.ts +205 -0
- package/src/custom/document-scanner/utils/perspective-correction.ts +139 -0
- package/src/custom/document-viewer/controllers.tsx +98 -0
- package/src/custom/document-viewer/index.tsx +43 -0
- package/src/custom/document-viewer/renderers/image.tsx +37 -0
- package/src/custom/document-viewer/renderers/index.tsx +2 -0
- package/src/custom/document-viewer/renderers/pdf.tsx +105 -0
- package/src/custom/email-input/domains.json +159 -0
- package/src/custom/email-input/email.tsx +229 -0
- package/src/custom/email-input/index.tsx +4 -0
- package/src/custom/email-input/types.ts +104 -0
- package/src/custom/file-uploader.tsx +541 -0
- package/src/custom/filter-component/fields/async-select.tsx +33 -0
- package/src/custom/filter-component/fields/date.tsx +60 -0
- package/src/custom/filter-component/fields/multi-select.tsx +30 -0
- package/src/custom/filter-component/index.tsx +217 -0
- package/src/custom/image-canvas.tsx +260 -0
- package/src/custom/json-editor.tsx +22 -0
- package/src/custom/master-data-grid/components/dialogs/column-settings-dialog.tsx +100 -0
- package/src/custom/master-data-grid/components/dialogs/index.ts +1 -0
- package/src/custom/master-data-grid/components/filters/client-filter.tsx +368 -0
- package/src/custom/master-data-grid/components/filters/filter-input.tsx +256 -0
- package/src/custom/master-data-grid/components/filters/index.ts +3 -0
- package/src/custom/master-data-grid/components/filters/inline-column-filter.tsx +233 -0
- package/src/custom/master-data-grid/components/filters/multi-filter-dialog.tsx +90 -0
- package/src/custom/master-data-grid/components/filters/server-filter.tsx +255 -0
- package/src/custom/master-data-grid/components/master-data-grid.tsx +472 -0
- package/src/custom/master-data-grid/components/pagination/index.ts +1 -0
- package/src/custom/master-data-grid/components/pagination/pagination.tsx +178 -0
- package/src/custom/master-data-grid/components/table/cell-renderer.tsx +634 -0
- package/src/custom/master-data-grid/components/table/header-cell.tsx +162 -0
- package/src/custom/master-data-grid/components/table/index.ts +4 -0
- package/src/custom/master-data-grid/components/table/table-body-renderer.tsx +113 -0
- package/src/custom/master-data-grid/components/table/virtual-body.tsx +138 -0
- package/src/custom/master-data-grid/components/toolbar/index.ts +1 -0
- package/src/custom/master-data-grid/components/toolbar/toolbar.tsx +314 -0
- package/src/custom/master-data-grid/hooks/index.ts +3 -0
- package/src/custom/master-data-grid/hooks/use-columns.tsx +332 -0
- package/src/custom/master-data-grid/hooks/use-editing.ts +106 -0
- package/src/custom/master-data-grid/hooks/use-table-state-reducer.ts +157 -0
- package/src/custom/master-data-grid/hooks/use-table-state.ts +31 -0
- package/src/custom/master-data-grid/index.ts +16 -0
- package/src/custom/master-data-grid/types.ts +466 -0
- package/src/custom/master-data-grid/utils/column-generator.tsx +306 -0
- package/src/custom/master-data-grid/utils/export-utils.ts +67 -0
- package/src/custom/master-data-grid/utils/filter-fns.ts +290 -0
- package/src/custom/master-data-grid/utils/index.ts +8 -0
- package/src/custom/master-data-grid/utils/pinning-utils.ts +88 -0
- package/src/custom/master-data-grid/utils/translation-utils.ts +42 -0
- package/src/custom/multi-select.tsx +432 -0
- package/src/custom/password-input.tsx +194 -0
- package/src/custom/phone-input.tsx +172 -0
- package/src/custom/schema-form/custom/index.tsx +1 -0
- package/src/custom/schema-form/custom/label.tsx +53 -0
- package/src/custom/schema-form/fields/base-input-field.tsx +82 -0
- package/src/custom/schema-form/fields/field.tsx +67 -0
- package/src/custom/schema-form/fields/index.tsx +5 -0
- package/src/custom/schema-form/fields/object.tsx +12 -0
- package/src/custom/schema-form/fields/table-array/array-field-item.tsx +90 -0
- package/src/custom/schema-form/fields/table-array/array-field-template.tsx +115 -0
- package/src/custom/schema-form/index.tsx +259 -0
- package/src/custom/schema-form/templates/description.tsx +20 -0
- package/src/custom/schema-form/templates/index.tsx +2 -0
- package/src/custom/schema-form/templates/submit.tsx +32 -0
- package/src/custom/schema-form/types.ts +64 -0
- package/src/custom/schema-form/utils/index.ts +4 -0
- package/src/custom/schema-form/utils/schema-dependency.ts +655 -0
- package/src/custom/schema-form/utils/schemas.ts +289 -0
- package/src/custom/schema-form/utils/validation.ts +23 -0
- package/src/custom/schema-form/widgets/boolean.tsx +77 -0
- package/src/custom/schema-form/widgets/combobox.tsx +274 -0
- package/src/custom/schema-form/widgets/date.tsx +59 -0
- package/src/custom/schema-form/widgets/email.tsx +34 -0
- package/src/custom/schema-form/widgets/index.tsx +10 -0
- package/src/custom/schema-form/widgets/password.tsx +40 -0
- package/src/custom/schema-form/widgets/phone.tsx +40 -0
- package/src/custom/schema-form/widgets/select.tsx +105 -0
- package/src/custom/schema-form/widgets/selectable.tsx +25 -0
- package/src/custom/schema-form/widgets/string-array.tsx +296 -0
- package/src/custom/schema-form/widgets/url.tsx +56 -0
- package/src/custom/section-layout-v2.tsx +212 -0
- package/src/custom/select-tabs.tsx +109 -0
- package/src/custom/selectable.tsx +316 -0
- package/src/custom/stepper.tsx +236 -0
- package/src/custom/tab-layout.tsx +213 -0
- package/src/custom/tanstack-table/fields/index.tsx +12 -0
- package/src/custom/tanstack-table/fields/tanstack-table-action-dialogs.tsx +89 -0
- package/src/custom/tanstack-table/fields/tanstack-table-column-header.tsx +66 -0
- package/src/custom/tanstack-table/fields/tanstack-table-filter-date.tsx +180 -0
- package/src/custom/tanstack-table/fields/tanstack-table-filter-faceted.tsx +158 -0
- package/src/custom/tanstack-table/fields/tanstack-table-filter-text.tsx +76 -0
- package/src/custom/tanstack-table/fields/tanstack-table-pagination.tsx +136 -0
- package/src/custom/tanstack-table/fields/tanstack-table-plain-table.tsx +142 -0
- package/src/custom/tanstack-table/fields/tanstack-table-row-actions-confirmation.tsx +77 -0
- package/src/custom/tanstack-table/fields/tanstack-table-row-actions-custom-dialog.tsx +87 -0
- package/src/custom/tanstack-table/fields/tanstack-table-row-actions.tsx +151 -0
- package/src/custom/tanstack-table/fields/tanstack-table-table-actions-custom-dialog.tsx +88 -0
- package/src/custom/tanstack-table/fields/tanstack-table-table-actions-schemaform-dialog.tsx +47 -0
- package/src/custom/tanstack-table/fields/tanstack-table-toolbar.tsx +143 -0
- package/src/custom/tanstack-table/fields/tanstack-table-view-options.tsx +171 -0
- package/src/custom/tanstack-table/index.tsx +244 -0
- package/src/custom/tanstack-table/types/index.ts +328 -0
- package/src/custom/tanstack-table/utils/cell-with-actions.tsx +21 -0
- package/src/custom/tanstack-table/utils/column-names.ts +26 -0
- package/src/custom/tanstack-table/utils/columns-by-row-data.tsx +312 -0
- package/src/custom/tanstack-table/utils/editable-columns-by-row-data.tsx +219 -0
- package/src/custom/tanstack-table/utils/faceted-boolean-options.tsx +22 -0
- package/src/custom/tanstack-table/utils/index.tsx +10 -0
- package/src/custom/tanstack-table/utils/pinning-styles.ts +57 -0
- package/src/custom/tanstack-table/utils/table.tsx +83 -0
- package/src/custom/tanstack-table/utils/test-conditions.ts +17 -0
- package/src/custom/timeline.tsx +208 -0
- package/src/custom/tree.tsx +200 -0
- package/src/custom/tscanify/browser.ts +66 -0
- package/src/custom/tscanify/index.ts +51 -0
- package/src/custom/tscanify/tscanify-browser.ts +522 -0
- package/src/custom/tscanify/tscanify.ts +262 -0
- package/src/custom/tscanify/types.ts +22 -0
- package/src/custom/webcam.tsx +737 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/use-callback-ref.ts +27 -0
- package/src/hooks/use-controllable-state.ts +67 -0
- package/src/hooks/use-debounce.ts +19 -0
- package/src/hooks/use-is-visible.ts +23 -0
- package/src/hooks/use-media-query.ts +21 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/hooks/use-on-window-resize.ts +15 -0
- package/src/hooks/use-scroll.tsx +22 -0
- package/src/lib/utils.ts +61 -0
- package/src/lib/zod.ts +2 -0
- package/src/styles/core.css +57 -0
- package/src/styles/globals.css +130 -0
- package/src/test/email-input.test.tsx +217 -0
- package/src/test/password-input.test.tsx +92 -0
- package/src/test/select-tabs.test.tsx +302 -0
- package/src/test/selectable.test.tsx +1093 -0
- package/tsconfig.json +13 -0
- 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
|
+
});
|