@fragments-sdk/ui 0.8.7 → 0.8.8
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/fragments.json +1 -1
- package/package.json +2 -2
- package/src/assets/fragments-logo.tsx +9 -8
- package/src/blocks/CommandPalette.block.ts +34 -0
- package/src/blocks/PaginatedTable.block.ts +36 -0
- package/src/blocks/SettingsDrawer.block.ts +47 -0
- package/src/components/Command/Command.fragment.tsx +237 -0
- package/src/components/Command/Command.module.scss +153 -0
- package/src/components/Command/Command.test.tsx +363 -0
- package/src/components/Command/index.tsx +502 -0
- package/src/components/Drawer/Drawer.fragment.tsx +206 -0
- package/src/components/Drawer/Drawer.module.scss +215 -0
- package/src/components/Drawer/Drawer.test.tsx +227 -0
- package/src/components/Drawer/index.tsx +239 -0
- package/src/components/Pagination/Pagination.fragment.tsx +152 -0
- package/src/components/Pagination/Pagination.module.scss +109 -0
- package/src/components/Pagination/Pagination.test.tsx +171 -0
- package/src/components/Pagination/index.tsx +360 -0
- package/src/components/Tooltip/index.tsx +25 -1
- package/src/index.ts +34 -1
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, waitFor, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Command } from './index';
|
|
4
|
+
import { Dialog } from '../Dialog';
|
|
5
|
+
|
|
6
|
+
function renderCommand(props: Partial<React.ComponentProps<typeof Command>> = {}) {
|
|
7
|
+
return render(
|
|
8
|
+
<Command {...props}>
|
|
9
|
+
<Command.Input placeholder="Type a command..." />
|
|
10
|
+
<Command.List>
|
|
11
|
+
<Command.Item onItemSelect={() => {}}>Open File</Command.Item>
|
|
12
|
+
<Command.Item onItemSelect={() => {}}>Save Document</Command.Item>
|
|
13
|
+
<Command.Item onItemSelect={() => {}}>Print</Command.Item>
|
|
14
|
+
<Command.Empty>No results found.</Command.Empty>
|
|
15
|
+
</Command.List>
|
|
16
|
+
</Command>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('Command', () => {
|
|
21
|
+
it('renders input and items', () => {
|
|
22
|
+
renderCommand();
|
|
23
|
+
|
|
24
|
+
expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByText('Open File')).toBeInTheDocument();
|
|
26
|
+
expect(screen.getByText('Save Document')).toBeInTheDocument();
|
|
27
|
+
expect(screen.getByText('Print')).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('typing filters items', async () => {
|
|
31
|
+
const user = userEvent.setup();
|
|
32
|
+
renderCommand();
|
|
33
|
+
|
|
34
|
+
const input = screen.getByPlaceholderText('Type a command...');
|
|
35
|
+
await user.type(input, 'open');
|
|
36
|
+
|
|
37
|
+
await waitFor(() => {
|
|
38
|
+
expect(screen.getByText('Open File')).toBeVisible();
|
|
39
|
+
expect(screen.getByText('Save Document')).not.toBeVisible();
|
|
40
|
+
expect(screen.getByText('Print')).not.toBeVisible();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('filtered-out items are hidden', async () => {
|
|
45
|
+
const user = userEvent.setup();
|
|
46
|
+
renderCommand();
|
|
47
|
+
|
|
48
|
+
const input = screen.getByPlaceholderText('Type a command...');
|
|
49
|
+
await user.type(input, 'save');
|
|
50
|
+
|
|
51
|
+
await waitFor(() => {
|
|
52
|
+
expect(screen.getByText('Save Document')).toBeVisible();
|
|
53
|
+
expect(screen.getByText('Open File')).not.toBeVisible();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('empty state shows when no matches', async () => {
|
|
58
|
+
const user = userEvent.setup();
|
|
59
|
+
renderCommand();
|
|
60
|
+
|
|
61
|
+
const input = screen.getByPlaceholderText('Type a command...');
|
|
62
|
+
await user.type(input, 'zzzzz');
|
|
63
|
+
|
|
64
|
+
await waitFor(() => {
|
|
65
|
+
expect(screen.getByText('No results found.')).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('groups auto-hide when all children filtered', async () => {
|
|
70
|
+
const user = userEvent.setup();
|
|
71
|
+
render(
|
|
72
|
+
<Command>
|
|
73
|
+
<Command.Input placeholder="Search..." />
|
|
74
|
+
<Command.List>
|
|
75
|
+
<Command.Group heading="Files">
|
|
76
|
+
<Command.Item onItemSelect={() => {}}>Open File</Command.Item>
|
|
77
|
+
</Command.Group>
|
|
78
|
+
<Command.Group heading="Edit">
|
|
79
|
+
<Command.Item onItemSelect={() => {}}>Copy</Command.Item>
|
|
80
|
+
<Command.Item onItemSelect={() => {}}>Paste</Command.Item>
|
|
81
|
+
</Command.Group>
|
|
82
|
+
<Command.Empty>No results.</Command.Empty>
|
|
83
|
+
</Command.List>
|
|
84
|
+
</Command>
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const input = screen.getByPlaceholderText('Search...');
|
|
88
|
+
await user.type(input, 'copy');
|
|
89
|
+
|
|
90
|
+
await waitFor(() => {
|
|
91
|
+
// "Files" group should be hidden since "Open File" doesn't match "copy"
|
|
92
|
+
const filesGroup = screen.getByText('Files').closest('[role="group"]');
|
|
93
|
+
expect(filesGroup).toHaveStyle({ display: 'none' });
|
|
94
|
+
|
|
95
|
+
// "Edit" group should be visible since "Copy" matches
|
|
96
|
+
const editGroup = screen.getByText('Edit').closest('[role="group"]');
|
|
97
|
+
expect(editGroup).not.toHaveStyle({ display: 'none' });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('custom filter function works', async () => {
|
|
102
|
+
const user = userEvent.setup();
|
|
103
|
+
const customFilter = vi.fn((value: string, search: string) => {
|
|
104
|
+
// Only match exact start
|
|
105
|
+
return value.toLowerCase().startsWith(search.toLowerCase()) ? 1 : 0;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
render(
|
|
109
|
+
<Command filter={customFilter}>
|
|
110
|
+
<Command.Input placeholder="Search..." />
|
|
111
|
+
<Command.List>
|
|
112
|
+
<Command.Item onItemSelect={() => {}}>Apple</Command.Item>
|
|
113
|
+
<Command.Item onItemSelect={() => {}}>Banana</Command.Item>
|
|
114
|
+
<Command.Item onItemSelect={() => {}}>Apricot</Command.Item>
|
|
115
|
+
<Command.Empty>No results.</Command.Empty>
|
|
116
|
+
</Command.List>
|
|
117
|
+
</Command>
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const input = screen.getByPlaceholderText('Search...');
|
|
121
|
+
await user.type(input, 'ap');
|
|
122
|
+
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
expect(screen.getByText('Apple')).toBeVisible();
|
|
125
|
+
expect(screen.getByText('Apricot')).toBeVisible();
|
|
126
|
+
expect(screen.getByText('Banana')).not.toBeVisible();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('ArrowDown/ArrowUp navigates items', async () => {
|
|
131
|
+
const user = userEvent.setup();
|
|
132
|
+
renderCommand();
|
|
133
|
+
|
|
134
|
+
const input = screen.getByPlaceholderText('Type a command...');
|
|
135
|
+
input.focus();
|
|
136
|
+
|
|
137
|
+
await user.keyboard('{ArrowDown}');
|
|
138
|
+
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
const activeItem = document.querySelector('[data-active="true"]');
|
|
141
|
+
expect(activeItem).toBeTruthy();
|
|
142
|
+
expect(activeItem?.textContent).toBe('Open File');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await user.keyboard('{ArrowDown}');
|
|
146
|
+
|
|
147
|
+
await waitFor(() => {
|
|
148
|
+
const activeItem = document.querySelector('[data-active="true"]');
|
|
149
|
+
expect(activeItem?.textContent).toBe('Save Document');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await user.keyboard('{ArrowUp}');
|
|
153
|
+
|
|
154
|
+
await waitFor(() => {
|
|
155
|
+
const activeItem = document.querySelector('[data-active="true"]');
|
|
156
|
+
expect(activeItem?.textContent).toBe('Open File');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('Enter selects active item (calls onItemSelect)', async () => {
|
|
161
|
+
const user = userEvent.setup();
|
|
162
|
+
const onItemSelect = vi.fn();
|
|
163
|
+
|
|
164
|
+
render(
|
|
165
|
+
<Command>
|
|
166
|
+
<Command.Input placeholder="Search..." />
|
|
167
|
+
<Command.List>
|
|
168
|
+
<Command.Item onItemSelect={onItemSelect}>First</Command.Item>
|
|
169
|
+
<Command.Item onItemSelect={() => {}}>Second</Command.Item>
|
|
170
|
+
</Command.List>
|
|
171
|
+
</Command>
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const input = screen.getByPlaceholderText('Search...');
|
|
175
|
+
input.focus();
|
|
176
|
+
|
|
177
|
+
await user.keyboard('{ArrowDown}');
|
|
178
|
+
await user.keyboard('{Enter}');
|
|
179
|
+
|
|
180
|
+
expect(onItemSelect).toHaveBeenCalledTimes(1);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('click selects item', async () => {
|
|
184
|
+
const user = userEvent.setup();
|
|
185
|
+
const onItemSelect = vi.fn();
|
|
186
|
+
|
|
187
|
+
render(
|
|
188
|
+
<Command>
|
|
189
|
+
<Command.Input placeholder="Search..." />
|
|
190
|
+
<Command.List>
|
|
191
|
+
<Command.Item onItemSelect={onItemSelect}>Click Me</Command.Item>
|
|
192
|
+
</Command.List>
|
|
193
|
+
</Command>
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
await user.click(screen.getByText('Click Me'));
|
|
197
|
+
expect(onItemSelect).toHaveBeenCalledTimes(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('disabled items are skipped in keyboard nav', async () => {
|
|
201
|
+
const user = userEvent.setup();
|
|
202
|
+
render(
|
|
203
|
+
<Command>
|
|
204
|
+
<Command.Input placeholder="Search..." />
|
|
205
|
+
<Command.List>
|
|
206
|
+
<Command.Item onItemSelect={() => {}}>First</Command.Item>
|
|
207
|
+
<Command.Item disabled onItemSelect={() => {}}>Disabled</Command.Item>
|
|
208
|
+
<Command.Item onItemSelect={() => {}}>Third</Command.Item>
|
|
209
|
+
</Command.List>
|
|
210
|
+
</Command>
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const input = screen.getByPlaceholderText('Search...');
|
|
214
|
+
input.focus();
|
|
215
|
+
|
|
216
|
+
await user.keyboard('{ArrowDown}');
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
const activeItem = document.querySelector('[data-active="true"]');
|
|
219
|
+
expect(activeItem?.textContent).toBe('First');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await user.keyboard('{ArrowDown}');
|
|
223
|
+
await waitFor(() => {
|
|
224
|
+
const activeItem = document.querySelector('[data-active="true"]');
|
|
225
|
+
// Skips "Disabled", goes to "Third"
|
|
226
|
+
expect(activeItem?.textContent).toBe('Third');
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('Home/End jump to first/last', async () => {
|
|
231
|
+
const user = userEvent.setup();
|
|
232
|
+
renderCommand();
|
|
233
|
+
|
|
234
|
+
const input = screen.getByPlaceholderText('Type a command...');
|
|
235
|
+
input.focus();
|
|
236
|
+
|
|
237
|
+
await user.keyboard('{End}');
|
|
238
|
+
await waitFor(() => {
|
|
239
|
+
const activeItem = document.querySelector('[data-active="true"]');
|
|
240
|
+
expect(activeItem?.textContent).toBe('Print');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await user.keyboard('{Home}');
|
|
244
|
+
await waitFor(() => {
|
|
245
|
+
const activeItem = document.querySelector('[data-active="true"]');
|
|
246
|
+
expect(activeItem?.textContent).toBe('Open File');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('loop={false} stops at boundaries', async () => {
|
|
251
|
+
const user = userEvent.setup();
|
|
252
|
+
render(
|
|
253
|
+
<Command loop={false}>
|
|
254
|
+
<Command.Input placeholder="Search..." />
|
|
255
|
+
<Command.List>
|
|
256
|
+
<Command.Item onItemSelect={() => {}}>First</Command.Item>
|
|
257
|
+
<Command.Item onItemSelect={() => {}}>Last</Command.Item>
|
|
258
|
+
</Command.List>
|
|
259
|
+
</Command>
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const input = screen.getByPlaceholderText('Search...');
|
|
263
|
+
input.focus();
|
|
264
|
+
|
|
265
|
+
// Navigate to last
|
|
266
|
+
await user.keyboard('{ArrowDown}');
|
|
267
|
+
await user.keyboard('{ArrowDown}');
|
|
268
|
+
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
const activeItem = document.querySelector('[data-active="true"]');
|
|
271
|
+
expect(activeItem?.textContent).toBe('Last');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Try to go past last — should stay on Last
|
|
275
|
+
await user.keyboard('{ArrowDown}');
|
|
276
|
+
await waitFor(() => {
|
|
277
|
+
const activeItem = document.querySelector('[data-active="true"]');
|
|
278
|
+
expect(activeItem?.textContent).toBe('Last');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('search value controlled mode', async () => {
|
|
283
|
+
const user = userEvent.setup();
|
|
284
|
+
const onSearchChange = vi.fn();
|
|
285
|
+
|
|
286
|
+
const { rerender } = render(
|
|
287
|
+
<Command search="open" onSearchChange={onSearchChange}>
|
|
288
|
+
<Command.Input placeholder="Search..." />
|
|
289
|
+
<Command.List>
|
|
290
|
+
<Command.Item onItemSelect={() => {}}>Open File</Command.Item>
|
|
291
|
+
<Command.Item onItemSelect={() => {}}>Save</Command.Item>
|
|
292
|
+
<Command.Empty>No results.</Command.Empty>
|
|
293
|
+
</Command.List>
|
|
294
|
+
</Command>
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
await waitFor(() => {
|
|
298
|
+
expect(screen.getByText('Open File')).toBeVisible();
|
|
299
|
+
expect(screen.getByText('Save')).not.toBeVisible();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Type in input triggers onSearchChange
|
|
303
|
+
const input = screen.getByPlaceholderText('Search...');
|
|
304
|
+
await user.type(input, 'x');
|
|
305
|
+
expect(onSearchChange).toHaveBeenCalled();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('items with keywords match on keywords', async () => {
|
|
309
|
+
const user = userEvent.setup();
|
|
310
|
+
render(
|
|
311
|
+
<Command>
|
|
312
|
+
<Command.Input placeholder="Search..." />
|
|
313
|
+
<Command.List>
|
|
314
|
+
<Command.Item keywords={['shortcut', 'hotkey']} onItemSelect={() => {}}>
|
|
315
|
+
Keyboard Settings
|
|
316
|
+
</Command.Item>
|
|
317
|
+
<Command.Item onItemSelect={() => {}}>Display Settings</Command.Item>
|
|
318
|
+
<Command.Empty>No results.</Command.Empty>
|
|
319
|
+
</Command.List>
|
|
320
|
+
</Command>
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const input = screen.getByPlaceholderText('Search...');
|
|
324
|
+
await user.type(input, 'hotkey');
|
|
325
|
+
|
|
326
|
+
await waitFor(() => {
|
|
327
|
+
expect(screen.getByText('Keyboard Settings')).toBeVisible();
|
|
328
|
+
expect(screen.getByText('Display Settings')).not.toBeVisible();
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('compose inside Dialog (command palette usage)', async () => {
|
|
333
|
+
const user = userEvent.setup();
|
|
334
|
+
render(
|
|
335
|
+
<Dialog>
|
|
336
|
+
<Dialog.Trigger>Open Palette</Dialog.Trigger>
|
|
337
|
+
<Dialog.Content size="sm">
|
|
338
|
+
<Command>
|
|
339
|
+
<Command.Input placeholder="Search commands..." />
|
|
340
|
+
<Command.List>
|
|
341
|
+
<Command.Item onItemSelect={() => {}}>New File</Command.Item>
|
|
342
|
+
<Command.Item onItemSelect={() => {}}>Open Recent</Command.Item>
|
|
343
|
+
</Command.List>
|
|
344
|
+
</Command>
|
|
345
|
+
</Dialog.Content>
|
|
346
|
+
</Dialog>
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
await user.click(screen.getByRole('button', { name: /open palette/i }));
|
|
350
|
+
|
|
351
|
+
await waitFor(() => {
|
|
352
|
+
expect(screen.getByPlaceholderText('Search commands...')).toBeInTheDocument();
|
|
353
|
+
expect(screen.getByText('New File')).toBeInTheDocument();
|
|
354
|
+
expect(screen.getByText('Open Recent')).toBeInTheDocument();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('has no accessibility violations', async () => {
|
|
359
|
+
const { container } = renderCommand();
|
|
360
|
+
|
|
361
|
+
await expectNoA11yViolations(container);
|
|
362
|
+
});
|
|
363
|
+
});
|