@arbor-education/design-system.components 0.18.0 → 0.19.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 (32) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/components/combobox/useComboboxListboxKeyboard.d.ts.map +1 -1
  3. package/dist/components/combobox/useComboboxListboxKeyboard.js +2 -0
  4. package/dist/components/combobox/useComboboxListboxKeyboard.js.map +1 -1
  5. package/dist/components/combobox/useComboboxListboxKeyboard.test.js +1 -0
  6. package/dist/components/combobox/useComboboxListboxKeyboard.test.js.map +1 -1
  7. package/dist/components/table/Table.d.ts +7 -0
  8. package/dist/components/table/Table.d.ts.map +1 -1
  9. package/dist/components/table/Table.js +3 -0
  10. package/dist/components/table/Table.js.map +1 -1
  11. package/dist/components/table/Table.stories.d.ts +2 -0
  12. package/dist/components/table/Table.stories.d.ts.map +1 -1
  13. package/dist/components/table/Table.stories.js +148 -2
  14. package/dist/components/table/Table.stories.js.map +1 -1
  15. package/dist/components/table/cellRenderers/ComboboxCellRenderer.d.ts +13 -0
  16. package/dist/components/table/cellRenderers/ComboboxCellRenderer.d.ts.map +1 -0
  17. package/dist/components/table/cellRenderers/ComboboxCellRenderer.js +72 -0
  18. package/dist/components/table/cellRenderers/ComboboxCellRenderer.js.map +1 -0
  19. package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.d.ts +2 -0
  20. package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.d.ts.map +1 -0
  21. package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.js +324 -0
  22. package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.js.map +1 -0
  23. package/dist/index.css +3 -0
  24. package/dist/index.css.map +1 -1
  25. package/package.json +1 -1
  26. package/src/components/combobox/useComboboxListboxKeyboard.test.tsx +1 -0
  27. package/src/components/combobox/useComboboxListboxKeyboard.ts +2 -0
  28. package/src/components/table/Table.stories.tsx +180 -2
  29. package/src/components/table/Table.tsx +3 -0
  30. package/src/components/table/cellRenderers/ComboboxCellRenderer.test.tsx +437 -0
  31. package/src/components/table/cellRenderers/ComboboxCellRenderer.tsx +135 -0
  32. package/src/components/table/table.scss +4 -0
@@ -1567,7 +1567,7 @@ const marksheetTidyTableColumnDefs: (ColDef | ColGroupDef)[] = [
1567
1567
  {
1568
1568
  headerName: 'Assessment Component',
1569
1569
  field: 'assessmentComponent',
1570
- cellRenderer: 'dsSelectDropdownCellRenderer',
1570
+ cellRenderer: 'dsComboboxCellRenderer',
1571
1571
  cellRendererParams: (params: CustomCellRendererProps) => {
1572
1572
  return {
1573
1573
  options: assessmentComponentOptions,
@@ -1576,13 +1576,15 @@ const marksheetTidyTableColumnDefs: (ColDef | ColGroupDef)[] = [
1576
1576
  };
1577
1577
  },
1578
1578
  editable: false,
1579
+ ...Table.ComboboxCellRenderer.colDefDefaults,
1579
1580
  },
1580
1581
  {
1581
1582
  headerName: 'Visibility',
1582
1583
  field: 'visibility',
1583
- cellRenderer: 'dsSelectDropdownCellRenderer',
1584
+ cellRenderer: 'dsComboboxCellRenderer',
1584
1585
  cellRendererParams: { options: visibilityOptions, placeholder: 'Select' },
1585
1586
  editable: false,
1587
+ ...Table.ComboboxCellRenderer.colDefDefaults,
1586
1588
  },
1587
1589
  {
1588
1590
  headerName: 'Editable',
@@ -2226,4 +2228,180 @@ export const WithCheckboxCellRenderer: Story = {
2226
2228
  },
2227
2229
  };
2228
2230
 
2231
+ interface SelectDropdownRowData {
2232
+ id: number;
2233
+ name: string;
2234
+ status: string | null;
2235
+ priority: string | null;
2236
+ category: string | null;
2237
+ }
2238
+
2239
+ const statusOptions = [
2240
+ { value: 'active', label: 'Active' },
2241
+ { value: 'inactive', label: 'Inactive' },
2242
+ { value: 'pending', label: 'Pending' },
2243
+ ];
2244
+
2245
+ const priorityOptions = [
2246
+ { value: 'high', label: 'High' },
2247
+ { value: 'medium', label: 'Medium' },
2248
+ { value: 'low', label: 'Low' },
2249
+ ];
2250
+
2251
+ const categoryOptions = [
2252
+ { value: 'english', label: 'English' },
2253
+ { value: 'maths', label: 'Maths' },
2254
+ { value: 'science', label: 'Science' },
2255
+ { value: 'history', label: 'History' },
2256
+ ];
2257
+
2258
+ const selectDropdownSampleData: SelectDropdownRowData[] = [
2259
+ { id: 1, name: 'Alice Johnson', status: 'active', priority: 'high', category: 'english' },
2260
+ { id: 2, name: 'Bob Smith', status: 'inactive', priority: null, category: 'maths' },
2261
+ { id: 3, name: 'Charlie Brown', status: null, priority: 'medium', category: null },
2262
+ { id: 4, name: 'Diana Prince', status: 'pending', priority: 'low', category: 'science' },
2263
+ { id: 5, name: 'Eve Wilson', status: 'active', priority: null, category: 'history' },
2264
+ ];
2265
+
2266
+ const selectDropdownColDefs: (ColDef | ColGroupDef)[] = [
2267
+ { field: 'name', headerName: 'Student Name', editable: false },
2268
+ {
2269
+ field: 'status',
2270
+ headerName: 'Status',
2271
+ cellRenderer: 'dsSelectDropdownCellRenderer',
2272
+ cellRendererParams: { options: statusOptions, placeholder: 'Select status' },
2273
+ editable: false,
2274
+ },
2275
+ {
2276
+ field: 'priority',
2277
+ headerName: 'Priority',
2278
+ cellRenderer: 'dsSelectDropdownCellRenderer',
2279
+ cellRendererParams: { options: priorityOptions, placeholder: 'Select priority' },
2280
+ editable: false,
2281
+ },
2282
+ {
2283
+ field: 'category',
2284
+ headerName: 'Category',
2285
+ cellRenderer: 'dsSelectDropdownCellRenderer',
2286
+ cellRendererParams: { options: categoryOptions, placeholder: 'Select category' },
2287
+ editable: false,
2288
+ },
2289
+ ];
2290
+
2291
+ export const WithSelectDropdownCellRenderer: Story = {
2292
+ parameters: {
2293
+ docs: {
2294
+ description: {
2295
+ story:
2296
+ 'Columns can use the `dsSelectDropdownCellRenderer` cell renderer to display a dropdown select inside the cell. Click or press Enter on a cell to open the dropdown.',
2297
+ },
2298
+ },
2299
+ },
2300
+ args: {
2301
+ rowData: selectDropdownSampleData,
2302
+ columnDefs: selectDropdownColDefs,
2303
+ defaultColDef,
2304
+ domLayout: 'autoHeight',
2305
+ },
2306
+ };
2307
+
2308
+ interface ComboboxRowData {
2309
+ id: number;
2310
+ name: string;
2311
+ subject: string | null;
2312
+ level: string | null;
2313
+ tags: string[];
2314
+ skills: string[];
2315
+ }
2316
+
2317
+ const subjectOptions = [
2318
+ { value: 'english-language', label: 'English Language' },
2319
+ { value: 'english-literature', label: 'English Literature' },
2320
+ { value: 'maths', label: 'Maths' },
2321
+ { value: 'science', label: 'Science' },
2322
+ { value: 'history', label: 'History' },
2323
+ ];
2324
+
2325
+ const levelOptions = [
2326
+ { value: 'foundation', label: 'Foundation' },
2327
+ { value: 'intermediate', label: 'Intermediate' },
2328
+ { value: 'higher', label: 'Higher' },
2329
+ ];
2330
+
2331
+ const tagOptions = [
2332
+ { value: 'coursework', label: 'Coursework' },
2333
+ { value: 'exam', label: 'Exam' },
2334
+ { value: 'practical', label: 'Practical' },
2335
+ { value: 'oral', label: 'Oral' },
2336
+ ];
2337
+
2338
+ const skillOptions = [
2339
+ { value: 'reading', label: 'Reading' },
2340
+ { value: 'writing', label: 'Writing' },
2341
+ { value: 'speaking', label: 'Speaking' },
2342
+ { value: 'listening', label: 'Listening' },
2343
+ { value: 'critical-thinking', label: 'Critical Thinking' },
2344
+ ];
2345
+
2346
+ const comboboxSampleData: ComboboxRowData[] = [
2347
+ { id: 1, name: 'Alice Johnson', subject: 'english-language', level: 'higher', tags: ['coursework', 'exam'], skills: ['reading', 'writing', 'speaking'] },
2348
+ { id: 2, name: 'Bob Smith', subject: 'maths', level: null, tags: ['exam'], skills: ['critical-thinking'] },
2349
+ { id: 3, name: 'Charlie Brown', subject: null, level: 'foundation', tags: [], skills: [] },
2350
+ { id: 4, name: 'Diana Prince', subject: 'english-literature', level: 'intermediate', tags: ['coursework', 'practical', 'oral'], skills: ['reading', 'writing', 'listening', 'speaking', 'critical-thinking'] },
2351
+ { id: 5, name: 'Eve Wilson', subject: 'science', level: null, tags: [], skills: ['listening'] },
2352
+ ];
2353
+
2354
+ const comboboxColDefs: (ColDef | ColGroupDef)[] = [
2355
+ { field: 'name', headerName: 'Student Name', editable: false },
2356
+ {
2357
+ field: 'subject',
2358
+ headerName: 'Subject (single, input)',
2359
+ cellRenderer: 'dsComboboxCellRenderer',
2360
+ cellRendererParams: { options: subjectOptions, placeholder: 'Select subject' },
2361
+ editable: false,
2362
+ ...Table.ComboboxCellRenderer.colDefDefaults,
2363
+ },
2364
+ {
2365
+ field: 'level',
2366
+ headerName: 'Level (single, button)',
2367
+ cellRenderer: 'dsComboboxCellRenderer',
2368
+ cellRendererParams: { options: levelOptions, placeholder: 'Select level', triggerVariant: 'button' },
2369
+ editable: false,
2370
+ ...Table.ComboboxCellRenderer.colDefDefaults,
2371
+ },
2372
+ {
2373
+ field: 'tags',
2374
+ headerName: 'Tags (multi, button)',
2375
+ cellRenderer: 'dsComboboxCellRenderer',
2376
+ cellRendererParams: { options: tagOptions, placeholder: 'Select tags', multiple: true, triggerVariant: 'button' },
2377
+ editable: false,
2378
+ ...Table.ComboboxCellRenderer.colDefDefaults,
2379
+ },
2380
+ {
2381
+ field: 'skills',
2382
+ headerName: 'Skills (multi, input)',
2383
+ cellRenderer: 'dsComboboxCellRenderer',
2384
+ cellRendererParams: { options: skillOptions, placeholder: 'Select skills', multiple: true },
2385
+ editable: false,
2386
+ ...Table.ComboboxCellRenderer.colDefDefaults,
2387
+ },
2388
+ ];
2389
+
2390
+ export const WithComboboxCellRenderer: Story = {
2391
+ parameters: {
2392
+ docs: {
2393
+ description: {
2394
+ story:
2395
+ 'Columns can use the `dsComboboxCellRenderer` cell renderer to display a searchable combobox inside the cell. Supports single and multi-select via the `multiple` prop. Row height adjusts automatically when chips wrap.',
2396
+ },
2397
+ },
2398
+ },
2399
+ args: {
2400
+ rowData: comboboxSampleData,
2401
+ columnDefs: comboboxColDefs,
2402
+ defaultColDef,
2403
+ domLayout: 'autoHeight',
2404
+ },
2405
+ };
2406
+
2229
2407
  export default meta;
@@ -33,6 +33,7 @@ import { TABLE_SPACING } from './tableConsts';
33
33
  import { TableSettingsContext } from './TableSettingsContext';
34
34
  import { BooleanCellRenderer } from './cellRenderers/BooleanCellRenderer';
35
35
  import { CheckboxCellRenderer } from './cellRenderers/CheckboxCellRenderer';
36
+ import { ComboboxCellRenderer } from './cellRenderers/ComboboxCellRenderer';
36
37
  import { DefaultCellRenderer } from './cellRenderers/DefaultCellRenderer';
37
38
 
38
39
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -198,6 +199,7 @@ export const Table = (props: TableProps) => {
198
199
  dsDateCellEditor: DateCellEditor,
199
200
  dsCheckboxCellRenderer: CheckboxCellRenderer,
200
201
  dsBooleanCellRenderer: BooleanCellRenderer,
202
+ dsComboboxCellRenderer: ComboboxCellRenderer,
201
203
  ...components,
202
204
  }}
203
205
  {...rest}
@@ -229,6 +231,7 @@ Table.DateCellEditor = DateCellEditor;
229
231
  Table.DefaultColDef = DSDefaultColDef;
230
232
  Table.GridApiContext = GridApiContext;
231
233
  Table.CheckboxCellRenderer = CheckboxCellRenderer;
234
+ Table.ComboboxCellRenderer = ComboboxCellRenderer;
232
235
  Table.DefaultValueFormatter = defaultValueFormatter;
233
236
  Table.TableSettingsDropdown = TableSettingsDropdown;
234
237
  Table.TableControls = TableControls;
@@ -0,0 +1,437 @@
1
+ import { describe, expect, test, vi, afterEach } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import '@testing-library/jest-dom/vitest';
4
+ import userEvent from '@testing-library/user-event';
5
+ import type { SuppressKeyboardEventParams } from 'ag-grid-community';
6
+ import type { CustomCellRendererProps } from 'ag-grid-react';
7
+ import { ComboboxCellRenderer } from './ComboboxCellRenderer';
8
+ import type { ComboboxOption, ComboboxProps } from 'Components/combobox/types';
9
+
10
+ const options: ComboboxOption[] = [
11
+ { value: 'opt1', label: 'Option 1' },
12
+ { value: 'opt2', label: 'Option 2' },
13
+ { value: 'opt3', label: 'Option 3' },
14
+ ];
15
+
16
+ type MockProps = Partial<CustomCellRendererProps> & Partial<Omit<ComboboxProps, 'value'>>;
17
+
18
+ const createMockProps = (overrides: MockProps = {}): CustomCellRendererProps & ComboboxProps => ({
19
+ value: null,
20
+ node: { setDataValue: vi.fn() },
21
+ column: 'testField',
22
+ api: {
23
+ addEventListener: vi.fn(),
24
+ removeEventListener: vi.fn(),
25
+ },
26
+ options,
27
+ ...overrides,
28
+ } as unknown as CustomCellRendererProps & ComboboxProps);
29
+
30
+ describe('ComboboxCellRenderer', () => {
31
+ test('renders a combobox input', () => {
32
+ render(<ComboboxCellRenderer {...createMockProps()} />);
33
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
34
+ });
35
+
36
+ test('renders with placeholder', () => {
37
+ render(<ComboboxCellRenderer {...createMockProps({ placeholder: 'Choose an option' })} />);
38
+ expect(screen.getByPlaceholderText('Choose an option')).toBeInTheDocument();
39
+ });
40
+
41
+ test('renders with default placeholder when none provided', () => {
42
+ render(<ComboboxCellRenderer {...createMockProps()} />);
43
+ expect(screen.getByPlaceholderText('Select...')).toBeInTheDocument();
44
+ });
45
+
46
+ test('opens listbox and shows options on input focus', async () => {
47
+ render(<ComboboxCellRenderer {...createMockProps()} />);
48
+ await userEvent.click(screen.getByRole('combobox'));
49
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
50
+ expect(screen.getByText('Option 1')).toBeInTheDocument();
51
+ });
52
+
53
+ test('calls setDataValue with the selected value for single select', async () => {
54
+ const setDataValue = vi.fn();
55
+ const props = createMockProps({
56
+ node: { setDataValue } as unknown as CustomCellRendererProps['node'],
57
+ column: 'testField' as unknown as CustomCellRendererProps['column'],
58
+ });
59
+ render(<ComboboxCellRenderer {...props} />);
60
+
61
+ await userEvent.click(screen.getByRole('combobox'));
62
+ await userEvent.click(screen.getByText('Option 1'));
63
+
64
+ expect(setDataValue).toHaveBeenCalledWith('testField', 'opt1');
65
+ });
66
+
67
+ test('calls setDataValue with array for multiple select', async () => {
68
+ const setDataValue = vi.fn();
69
+ const props = createMockProps({
70
+ multiple: true,
71
+ node: { setDataValue } as unknown as CustomCellRendererProps['node'],
72
+ column: 'testField' as unknown as CustomCellRendererProps['column'],
73
+ });
74
+ render(<ComboboxCellRenderer {...props} />);
75
+
76
+ await userEvent.click(screen.getByRole('combobox'));
77
+ await userEvent.click(screen.getByText('Option 1'));
78
+
79
+ expect(setDataValue).toHaveBeenCalledWith('testField', ['opt1']);
80
+ });
81
+
82
+ test('does not call setDataValue when column is undefined', async () => {
83
+ const setDataValue = vi.fn();
84
+ const props = createMockProps({
85
+ node: { setDataValue } as unknown as CustomCellRendererProps['node'],
86
+ column: undefined as unknown as CustomCellRendererProps['column'],
87
+ });
88
+ render(<ComboboxCellRenderer {...props} />);
89
+
90
+ await userEvent.click(screen.getByRole('combobox'));
91
+ await userEvent.click(screen.getByText('Option 1'));
92
+
93
+ expect(setDataValue).not.toHaveBeenCalled();
94
+ });
95
+
96
+ test('normalizes a string cell value to an array for controlled combobox', () => {
97
+ render(<ComboboxCellRenderer {...createMockProps({ value: 'opt1' })} />);
98
+ const input = screen.getByRole('combobox');
99
+ expect(input).toBeInTheDocument();
100
+ });
101
+
102
+ test('normalizes a string[] cell value to an array for controlled combobox', () => {
103
+ render(<ComboboxCellRenderer {...createMockProps({ value: ['opt1', 'opt2'], multiple: true })} />);
104
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
105
+ });
106
+
107
+ test('registers cellKeyDown listener on mount', () => {
108
+ const addEventListener = vi.fn();
109
+ const removeEventListener = vi.fn();
110
+ const props = createMockProps({
111
+ api: { addEventListener, removeEventListener } as unknown as CustomCellRendererProps['api'],
112
+ });
113
+ render(<ComboboxCellRenderer {...props} />);
114
+ expect(addEventListener).toHaveBeenCalledWith('cellKeyDown', expect.any(Function));
115
+ });
116
+
117
+ describe('value normalization edge cases', () => {
118
+ test('treats empty string value as no selection', () => {
119
+ render(<ComboboxCellRenderer {...createMockProps({ value: '' })} />);
120
+ expect(screen.getByPlaceholderText('Select...')).toBeInTheDocument();
121
+ });
122
+
123
+ test('normalizes a numeric cell value to a string', () => {
124
+ render(<ComboboxCellRenderer {...createMockProps({ value: 42 as unknown as string })} />);
125
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
126
+ });
127
+
128
+ test('normalizes a numeric array cell value to strings', () => {
129
+ render(<ComboboxCellRenderer {...createMockProps({ value: [1, 2] as unknown as string[], multiple: true })} />);
130
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
131
+ });
132
+ });
133
+
134
+ describe('cellKeyDown handler', () => {
135
+ test('removes the cellKeyDown listener on unmount', () => {
136
+ const addEventListener = vi.fn();
137
+ const removeEventListener = vi.fn();
138
+ const { unmount } = render(
139
+ <ComboboxCellRenderer {...createMockProps({
140
+ api: { addEventListener, removeEventListener } as unknown as CustomCellRendererProps['api'],
141
+ })}
142
+ />,
143
+ );
144
+ const listener = addEventListener.mock.calls[0]![1];
145
+ unmount();
146
+ expect(removeEventListener).toHaveBeenCalledWith('cellKeyDown', listener);
147
+ });
148
+
149
+ test('focuses the combobox trigger on Enter for matching node and column', () => {
150
+ const addEventListener = vi.fn();
151
+ const mockNode = { setDataValue: vi.fn() };
152
+ const mockColumn = 'testField';
153
+
154
+ render(
155
+ <ComboboxCellRenderer {...createMockProps({
156
+ node: mockNode as unknown as CustomCellRendererProps['node'],
157
+ column: mockColumn as unknown as CustomCellRendererProps['column'],
158
+ api: { addEventListener, removeEventListener: vi.fn() } as unknown as CustomCellRendererProps['api'],
159
+ })}
160
+ />,
161
+ );
162
+
163
+ const trigger = screen.getByRole('combobox');
164
+ const focusSpy = vi.spyOn(trigger, 'focus');
165
+
166
+ const listener = addEventListener.mock.calls[0]![1];
167
+ listener({ event: { key: 'Enter' }, node: mockNode, column: mockColumn });
168
+
169
+ expect(focusSpy).toHaveBeenCalled();
170
+ });
171
+
172
+ test('focuses and clicks the button trigger on Enter when triggerVariant is "button"', () => {
173
+ const addEventListener = vi.fn();
174
+ const mockNode = { setDataValue: vi.fn() };
175
+ const mockColumn = 'testField';
176
+
177
+ render(
178
+ <ComboboxCellRenderer {...createMockProps({
179
+ 'node': mockNode as unknown as CustomCellRendererProps['node'],
180
+ 'column': mockColumn as unknown as CustomCellRendererProps['column'],
181
+ 'api': { addEventListener, removeEventListener: vi.fn() } as unknown as CustomCellRendererProps['api'],
182
+ 'triggerVariant': 'button',
183
+ 'aria-label': 'Choose option',
184
+ })}
185
+ />,
186
+ );
187
+
188
+ const button = screen.getByRole('button', { name: 'Choose option' });
189
+ const focusSpy = vi.spyOn(button, 'focus');
190
+ const clickSpy = vi.spyOn(button, 'click');
191
+
192
+ const listener = addEventListener.mock.calls[0]![1];
193
+ listener({ event: { key: 'Enter' }, node: mockNode, column: mockColumn });
194
+
195
+ expect(focusSpy).toHaveBeenCalled();
196
+ expect(clickSpy).toHaveBeenCalled();
197
+ });
198
+
199
+ test('does not focus trigger when the event node does not match', () => {
200
+ const addEventListener = vi.fn();
201
+ const mockNode = { setDataValue: vi.fn() };
202
+ const differentNode = { setDataValue: vi.fn() };
203
+ const mockColumn = 'testField';
204
+
205
+ render(
206
+ <ComboboxCellRenderer {...createMockProps({
207
+ node: mockNode as unknown as CustomCellRendererProps['node'],
208
+ column: mockColumn as unknown as CustomCellRendererProps['column'],
209
+ api: { addEventListener, removeEventListener: vi.fn() } as unknown as CustomCellRendererProps['api'],
210
+ })}
211
+ />,
212
+ );
213
+
214
+ const trigger = screen.getByRole('combobox');
215
+ const focusSpy = vi.spyOn(trigger, 'focus');
216
+
217
+ const listener = addEventListener.mock.calls[0]![1];
218
+ listener({ event: { key: 'Enter' }, node: differentNode, column: mockColumn });
219
+
220
+ expect(focusSpy).not.toHaveBeenCalled();
221
+ });
222
+
223
+ test('does not focus trigger when the event column does not match', () => {
224
+ const addEventListener = vi.fn();
225
+ const mockNode = { setDataValue: vi.fn() };
226
+ const mockColumn = 'testField';
227
+
228
+ render(
229
+ <ComboboxCellRenderer {...createMockProps({
230
+ node: mockNode as unknown as CustomCellRendererProps['node'],
231
+ column: mockColumn as unknown as CustomCellRendererProps['column'],
232
+ api: { addEventListener, removeEventListener: vi.fn() } as unknown as CustomCellRendererProps['api'],
233
+ })}
234
+ />,
235
+ );
236
+
237
+ const trigger = screen.getByRole('combobox');
238
+ const focusSpy = vi.spyOn(trigger, 'focus');
239
+
240
+ const listener = addEventListener.mock.calls[0]![1];
241
+ listener({ event: { key: 'Enter' }, node: mockNode, column: 'differentField' });
242
+
243
+ expect(focusSpy).not.toHaveBeenCalled();
244
+ });
245
+
246
+ test('does not focus trigger for non-Enter keys', () => {
247
+ const addEventListener = vi.fn();
248
+ const mockNode = { setDataValue: vi.fn() };
249
+ const mockColumn = 'testField';
250
+
251
+ render(
252
+ <ComboboxCellRenderer {...createMockProps({
253
+ node: mockNode as unknown as CustomCellRendererProps['node'],
254
+ column: mockColumn as unknown as CustomCellRendererProps['column'],
255
+ api: { addEventListener, removeEventListener: vi.fn() } as unknown as CustomCellRendererProps['api'],
256
+ })}
257
+ />,
258
+ );
259
+
260
+ const trigger = screen.getByRole('combobox');
261
+ const focusSpy = vi.spyOn(trigger, 'focus');
262
+
263
+ const listener = addEventListener.mock.calls[0]![1];
264
+ listener({ event: { key: 'ArrowDown' }, node: mockNode, column: mockColumn });
265
+
266
+ expect(focusSpy).not.toHaveBeenCalled();
267
+ });
268
+
269
+ test('does not focus trigger for a FullWidthCellKeyDownEvent (no column property)', () => {
270
+ const addEventListener = vi.fn();
271
+ const mockNode = { setDataValue: vi.fn() };
272
+ const mockColumn = 'testField';
273
+
274
+ render(
275
+ <ComboboxCellRenderer {...createMockProps({
276
+ node: mockNode as unknown as CustomCellRendererProps['node'],
277
+ column: mockColumn as unknown as CustomCellRendererProps['column'],
278
+ api: { addEventListener, removeEventListener: vi.fn() } as unknown as CustomCellRendererProps['api'],
279
+ })}
280
+ />,
281
+ );
282
+
283
+ const trigger = screen.getByRole('combobox');
284
+ const focusSpy = vi.spyOn(trigger, 'focus');
285
+
286
+ const listener = addEventListener.mock.calls[0]![1];
287
+ listener({ event: { key: 'Enter' }, node: mockNode });
288
+
289
+ expect(focusSpy).not.toHaveBeenCalled();
290
+ });
291
+ });
292
+
293
+ describe('focus restoration via MutationObserver', () => {
294
+ test('calls setFocusedCell when popover closes without focus leaving the cell', async () => {
295
+ const setFocusedCell = vi.fn();
296
+ const mockNode = { setDataValue: vi.fn(), rowIndex: 5 };
297
+ const mockColumn = 'testField';
298
+
299
+ render(
300
+ <ComboboxCellRenderer {...createMockProps({
301
+ node: mockNode as unknown as CustomCellRendererProps['node'],
302
+ column: mockColumn as unknown as CustomCellRendererProps['column'],
303
+ api: {
304
+ addEventListener: vi.fn(),
305
+ removeEventListener: vi.fn(),
306
+ setFocusedCell,
307
+ } as unknown as CustomCellRendererProps['api'],
308
+ })}
309
+ />,
310
+ );
311
+
312
+ await userEvent.click(screen.getByRole('combobox'));
313
+ await userEvent.keyboard('{Escape}');
314
+
315
+ expect(setFocusedCell).toHaveBeenCalledWith(5, mockColumn);
316
+ });
317
+
318
+ test('does not call setFocusedCell when focus moves outside the cell', async () => {
319
+ const setFocusedCell = vi.fn();
320
+ const mockNode = { setDataValue: vi.fn(), rowIndex: 5 };
321
+ const mockColumn = 'testField';
322
+
323
+ render(
324
+ <>
325
+ <ComboboxCellRenderer {...createMockProps({
326
+ node: mockNode as unknown as CustomCellRendererProps['node'],
327
+ column: mockColumn as unknown as CustomCellRendererProps['column'],
328
+ api: {
329
+ addEventListener: vi.fn(),
330
+ removeEventListener: vi.fn(),
331
+ setFocusedCell,
332
+ } as unknown as CustomCellRendererProps['api'],
333
+ })}
334
+ />
335
+ <button>Outside</button>
336
+ </>,
337
+ );
338
+
339
+ await userEvent.click(screen.getByRole('combobox'));
340
+ await userEvent.click(screen.getByRole('button', { name: 'Outside' }));
341
+
342
+ expect(setFocusedCell).not.toHaveBeenCalled();
343
+ });
344
+
345
+ test('does not call setFocusedCell when node.rowIndex is null', async () => {
346
+ const setFocusedCell = vi.fn();
347
+ const mockNode = { setDataValue: vi.fn(), rowIndex: null };
348
+ const mockColumn = 'testField';
349
+
350
+ render(
351
+ <ComboboxCellRenderer {...createMockProps({
352
+ node: mockNode as unknown as CustomCellRendererProps['node'],
353
+ column: mockColumn as unknown as CustomCellRendererProps['column'],
354
+ api: {
355
+ addEventListener: vi.fn(),
356
+ removeEventListener: vi.fn(),
357
+ setFocusedCell,
358
+ } as unknown as CustomCellRendererProps['api'],
359
+ })}
360
+ />,
361
+ );
362
+
363
+ await userEvent.click(screen.getByRole('combobox'));
364
+ await userEvent.keyboard('{Escape}');
365
+
366
+ expect(setFocusedCell).not.toHaveBeenCalled();
367
+ });
368
+
369
+ test('disconnects the MutationObserver on unmount', () => {
370
+ const disconnect = vi.fn();
371
+ vi.stubGlobal('MutationObserver', vi.fn(() => ({ observe: vi.fn(), disconnect })));
372
+
373
+ try {
374
+ const { unmount } = render(<ComboboxCellRenderer {...createMockProps()} />);
375
+ unmount();
376
+ expect(disconnect).toHaveBeenCalled();
377
+ }
378
+ finally {
379
+ vi.unstubAllGlobals();
380
+ }
381
+ });
382
+ });
383
+
384
+ describe('colDefDefaults', () => {
385
+ test('autoHeight is true', () => {
386
+ expect(ComboboxCellRenderer.colDefDefaults.autoHeight).toBe(true);
387
+ });
388
+
389
+ describe('suppressKeyboardEvent', () => {
390
+ const { suppressKeyboardEvent } = ComboboxCellRenderer.colDefDefaults;
391
+
392
+ const appendCell = (key: string, open: boolean) => {
393
+ const cell = document.createElement('div');
394
+ cell.className = 'ag-cell';
395
+ const trigger = document.createElement('input');
396
+ trigger.setAttribute('role', 'combobox');
397
+ trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
398
+ cell.appendChild(trigger);
399
+ document.body.appendChild(cell);
400
+ return {
401
+ params: { event: { key, target: trigger } } as unknown as SuppressKeyboardEventParams,
402
+ cleanup: () => cell.remove(),
403
+ };
404
+ };
405
+
406
+ afterEach(() => {
407
+ document.querySelectorAll('.ag-cell').forEach(el => el.remove());
408
+ });
409
+
410
+ test('returns false when the event target has no ag-cell ancestor', () => {
411
+ const target = document.createElement('input');
412
+ document.body.appendChild(target);
413
+ const params = { event: { key: 'ArrowDown', target } } as unknown as SuppressKeyboardEventParams;
414
+ expect(suppressKeyboardEvent(params)).toBe(false);
415
+ target.remove();
416
+ });
417
+
418
+ test('returns false when the combobox is closed', () => {
419
+ const { params } = appendCell('ArrowDown', false);
420
+ expect(suppressKeyboardEvent(params)).toBe(false);
421
+ });
422
+
423
+ test.each(['ArrowUp', 'ArrowDown', 'Enter', 'Escape'])(
424
+ 'suppresses %s when the combobox is open',
425
+ (key) => {
426
+ const { params } = appendCell(key, true);
427
+ expect(suppressKeyboardEvent(params)).toBe(true);
428
+ },
429
+ );
430
+
431
+ test('does not suppress Tab when the combobox is open', () => {
432
+ const { params } = appendCell('Tab', true);
433
+ expect(suppressKeyboardEvent(params)).toBe(false);
434
+ });
435
+ });
436
+ });
437
+ });