@byline/admin 6.1.0 → 6.2.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 (97) hide show
  1. package/dist/forms/form-renderer.js +7 -2
  2. package/dist/presentation/tab-strip-drag.d.ts +75 -0
  3. package/dist/presentation/tab-strip-drag.js +25 -0
  4. package/dist/presentation/tab-strip-drag.test.node.d.ts +8 -0
  5. package/dist/presentation/tab-strip-geometry.d.ts +75 -0
  6. package/dist/presentation/tab-strip-geometry.js +36 -0
  7. package/dist/presentation/tab-strip-geometry.test.node.d.ts +8 -0
  8. package/dist/presentation/tabs.d.ts +30 -7
  9. package/dist/presentation/tabs.js +260 -29
  10. package/dist/presentation/tabs.module.js +11 -1
  11. package/dist/presentation/tabs_module.css +166 -5
  12. package/package.json +7 -7
  13. package/src/forms/form-renderer.tsx +10 -3
  14. package/src/presentation/tab-strip-drag.test.node.ts +143 -0
  15. package/src/presentation/tab-strip-drag.ts +114 -0
  16. package/src/presentation/tab-strip-geometry.test.node.ts +254 -0
  17. package/src/presentation/tab-strip-geometry.ts +158 -0
  18. package/src/presentation/tabs.module.css +209 -11
  19. package/src/presentation/tabs.test.tsx +481 -0
  20. package/src/presentation/tabs.tsx +369 -34
  21. package/dist/exports-parity.test.node.js +0 -37
  22. package/dist/fields/array/array-field.jsx +0 -209
  23. package/dist/fields/blocks/blocks-field.jsx +0 -248
  24. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  25. package/dist/fields/code/code-editor.jsx +0 -211
  26. package/dist/fields/code/code-field.jsx +0 -109
  27. package/dist/fields/column-formatter.jsx +0 -25
  28. package/dist/fields/date-time-formatter.jsx +0 -18
  29. package/dist/fields/datetime/datetime-field.jsx +0 -22
  30. package/dist/fields/draggable-context-menu.jsx +0 -50
  31. package/dist/fields/field-admin.test.node.js +0 -40
  32. package/dist/fields/field-renderer.jsx +0 -136
  33. package/dist/fields/field-services-context.jsx +0 -17
  34. package/dist/fields/file/file-field.jsx +0 -176
  35. package/dist/fields/file/file-upload-field.jsx +0 -89
  36. package/dist/fields/group/group-field.jsx +0 -46
  37. package/dist/fields/image/image-field.jsx +0 -183
  38. package/dist/fields/image/image-upload-field.jsx +0 -157
  39. package/dist/fields/local-date-time.jsx +0 -69
  40. package/dist/fields/locale-badge.jsx +0 -19
  41. package/dist/fields/numerical/numerical-field.jsx +0 -93
  42. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  43. package/dist/fields/relation/relation-display.jsx +0 -118
  44. package/dist/fields/relation/relation-field.jsx +0 -108
  45. package/dist/fields/relation/relation-many-field.jsx +0 -142
  46. package/dist/fields/relation/relation-picker.jsx +0 -240
  47. package/dist/fields/relation/relation-summary.jsx +0 -45
  48. package/dist/fields/select/select-field.jsx +0 -27
  49. package/dist/fields/sortable-item.jsx +0 -54
  50. package/dist/fields/text/text-field.jsx +0 -79
  51. package/dist/fields/text-area/text-area-field.jsx +0 -79
  52. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  53. package/dist/forms/available-locales-widget.jsx +0 -63
  54. package/dist/forms/document-actions.jsx +0 -486
  55. package/dist/forms/form-context.jsx +0 -598
  56. package/dist/forms/form-modals.jsx +0 -128
  57. package/dist/forms/form-renderer.jsx +0 -532
  58. package/dist/forms/form-status-display.jsx +0 -69
  59. package/dist/forms/navigation-guard.jsx +0 -68
  60. package/dist/forms/nested-path.test.node.js +0 -115
  61. package/dist/forms/path-widget.jsx +0 -125
  62. package/dist/forms/pending-uploads.test.node.js +0 -19
  63. package/dist/forms/repeating-items.test.node.js +0 -29
  64. package/dist/forms/scheduled-publication-control.jsx +0 -389
  65. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  66. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  67. package/dist/forms/status-transitions.test.node.js +0 -76
  68. package/dist/forms/tree-placement-widget.jsx +0 -160
  69. package/dist/forms/upload-executor.test.node.js +0 -368
  70. package/dist/forms/use-form-layout.test.node.js +0 -69
  71. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  72. package/dist/modules/admin-account/components/container.jsx +0 -164
  73. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  74. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  75. package/dist/modules/admin-account/components/update.jsx +0 -186
  76. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  77. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  78. package/dist/modules/admin-roles/components/create.jsx +0 -155
  79. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  80. package/dist/modules/admin-roles/components/update.jsx +0 -148
  81. package/dist/modules/admin-users/components/create.jsx +0 -206
  82. package/dist/modules/admin-users/components/roles.jsx +0 -126
  83. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  84. package/dist/modules/admin-users/components/update.jsx +0 -215
  85. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  86. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  87. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  88. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  89. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  90. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  91. package/dist/presentation/group.jsx +0 -26
  92. package/dist/presentation/row.jsx +0 -23
  93. package/dist/presentation/tabs.jsx +0 -39
  94. package/dist/services/admin-services-context.jsx +0 -17
  95. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  96. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  97. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -0,0 +1,481 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { act } from 'react'
10
+
11
+ import { adminTranslations } from '@byline/i18n/admin'
12
+ import { I18nProvider } from '@byline/i18n/react'
13
+ import { createRoot, type Root } from 'react-dom/client'
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
15
+
16
+ // Stub the uikit surface this component touches. The overflow menu is rendered
17
+ // open and inline so the assertions can read its items: base-ui's Menu portals
18
+ // into a floating layer that jsdom cannot position, and the contract under test
19
+ // is what AdminTabs puts *into* the menu, not base-ui's popup behaviour.
20
+ vi.mock('@byline/ui/react', () => ({
21
+ Badge: ({ children, className }: { children?: React.ReactNode; className?: string }) => (
22
+ <span className={className}>{children}</span>
23
+ ),
24
+ EllipsisIcon: ({ className }: { className?: string }) => (
25
+ <svg aria-hidden className={className} />
26
+ ),
27
+ Dropdown: {
28
+ Root: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
29
+ Trigger: ({ children, ...rest }: any) => (
30
+ <button type="button" {...rest}>
31
+ {children}
32
+ </button>
33
+ ),
34
+ Portal: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
35
+ Content: ({ children, ...rest }: any) => <div {...rest}>{children}</div>,
36
+ Item: ({ children, onClick, ...rest }: any) => (
37
+ <button type="button" role="menuitem" onClick={onClick} {...rest}>
38
+ {children}
39
+ </button>
40
+ ),
41
+ },
42
+ }))
43
+
44
+ // biome-ignore lint/correctness/useImportExtensions: package TS resolves via tsconfig paths
45
+ import { AdminTabs } from './tabs'
46
+
47
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
48
+
49
+ /**
50
+ * jsdom performs no layout, so every width is 0 and the strip would never
51
+ * report overflow. Drive the two widths the component reads off its stable
52
+ * override handles.
53
+ */
54
+ const layout = { container: 0, row: 0 }
55
+
56
+ function installPointerStubs() {
57
+ // jsdom implements neither pointer capture nor scrollTo; the component guards
58
+ // both, and these stubs let the drag path run end to end in a test.
59
+ HTMLElement.prototype.setPointerCapture = () => {}
60
+ HTMLElement.prototype.releasePointerCapture = () => {}
61
+ HTMLElement.prototype.hasPointerCapture = () => false
62
+ Object.defineProperty(HTMLElement.prototype, 'scrollLeft', {
63
+ configurable: true,
64
+ get(this: HTMLElement & { _scrollLeft?: number }) {
65
+ return this._scrollLeft ?? 0
66
+ },
67
+ set(this: HTMLElement & { _scrollLeft?: number }, value: number) {
68
+ this._scrollLeft = value
69
+ },
70
+ })
71
+ }
72
+
73
+ /** jsdom has no PointerEvent constructor, so synthesise one from MouseEvent. */
74
+ function pointerEvent(
75
+ type: string,
76
+ init: { clientX?: number; pointerType?: string; button?: number }
77
+ ) {
78
+ const event = new MouseEvent(type, {
79
+ bubbles: true,
80
+ cancelable: true,
81
+ clientX: init.clientX ?? 0,
82
+ button: init.button ?? 0,
83
+ })
84
+ Object.defineProperty(event, 'pointerType', { value: init.pointerType ?? 'mouse' })
85
+ Object.defineProperty(event, 'pointerId', { value: 1 })
86
+ return event
87
+ }
88
+
89
+ function installLayoutStubs() {
90
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
91
+ configurable: true,
92
+ get(this: HTMLElement) {
93
+ return this.classList.contains('byline-admin-tabs') ? layout.container : 0
94
+ },
95
+ })
96
+ Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
97
+ configurable: true,
98
+ get(this: HTMLElement) {
99
+ return this.classList.contains('byline-admin-tablist') ? layout.row : 0
100
+ },
101
+ })
102
+ }
103
+
104
+ const resizeCallbacks: ResizeObserverCallback[] = []
105
+ const observed: Element[] = []
106
+
107
+ class StubResizeObserver {
108
+ constructor(cb: ResizeObserverCallback) {
109
+ resizeCallbacks.push(cb)
110
+ }
111
+ observe(target: Element) {
112
+ observed.push(target)
113
+ }
114
+ unobserve() {}
115
+ disconnect() {}
116
+ }
117
+
118
+ const TABS = [
119
+ { name: 'details', label: 'Details' },
120
+ { name: 'classification', label: 'Classification' },
121
+ { name: 'bibliographic', label: 'Bibliographic' },
122
+ { name: 'abstract', label: 'Abstract' },
123
+ ]
124
+
125
+ describe('AdminTabs', () => {
126
+ let container: HTMLDivElement
127
+ let root: Root
128
+ let onChange: ReturnType<typeof vi.fn>
129
+
130
+ beforeEach(() => {
131
+ installLayoutStubs()
132
+ installPointerStubs()
133
+ ;(globalThis as any).ResizeObserver = StubResizeObserver
134
+ resizeCallbacks.length = 0
135
+ observed.length = 0
136
+ layout.container = 1000
137
+ layout.row = 400
138
+ onChange = vi.fn()
139
+ container = document.createElement('div')
140
+ document.body.appendChild(container)
141
+ root = createRoot(container)
142
+ })
143
+
144
+ afterEach(() => {
145
+ act(() => {
146
+ root.unmount()
147
+ })
148
+ container.remove()
149
+ })
150
+
151
+ const render = (props: Partial<React.ComponentProps<typeof AdminTabs>> = {}) => {
152
+ act(() => {
153
+ root.render(
154
+ // The real English admin bundle rather than a stubbed `t`, so a renamed
155
+ // or deleted key fails the test rather than passing silently.
156
+ <I18nProvider
157
+ bundle={adminTranslations({ locales: ['en'] })}
158
+ activeLocale="en"
159
+ defaultLocale="en"
160
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
161
+ >
162
+ <AdminTabs
163
+ idBase="tabset:main"
164
+ tabs={props.tabs ?? TABS}
165
+ activeTab={props.activeTab ?? 'details'}
166
+ onChange={props.onChange ?? onChange}
167
+ errorCounts={props.errorCounts}
168
+ />
169
+ </I18nProvider>
170
+ )
171
+ })
172
+ }
173
+
174
+ const tabButtons = () => Array.from(container.querySelectorAll<HTMLButtonElement>('[role="tab"]'))
175
+ const trigger = () =>
176
+ container.querySelector<HTMLButtonElement>('.byline-admin-tabs-overflow-trigger')
177
+ const menuItems = () => Array.from(container.querySelectorAll<HTMLElement>('[role="menuitem"]'))
178
+
179
+ it('renders every tab as a tab button', () => {
180
+ render()
181
+
182
+ expect(tabButtons().map((b) => b.textContent)).toEqual([
183
+ 'Details',
184
+ 'Classification',
185
+ 'Bibliographic',
186
+ 'Abstract',
187
+ ])
188
+ })
189
+
190
+ it('marks the active tab as selected', () => {
191
+ render({ activeTab: 'bibliographic' })
192
+
193
+ const selected = tabButtons().filter((b) => b.getAttribute('aria-selected') === 'true')
194
+ expect(selected.map((b) => b.textContent)).toEqual(['Bibliographic'])
195
+ })
196
+
197
+ it('does not render the overflow trigger when the row fits', () => {
198
+ layout.row = 400
199
+ render()
200
+
201
+ expect(trigger()).toBeNull()
202
+ })
203
+
204
+ it('renders the overflow trigger when the row does not fit', () => {
205
+ layout.row = 1600
206
+ render()
207
+
208
+ expect(trigger()).not.toBeNull()
209
+ })
210
+
211
+ /**
212
+ * The menu is a jump list, not a remainder list. Listing only the tabs that
213
+ * happen to be scrolled out would make the menu's contents shift under the
214
+ * reader as they scroll.
215
+ */
216
+ it('lists every tab in the overflow menu, not only the hidden ones', () => {
217
+ layout.row = 1600
218
+ render()
219
+
220
+ expect(menuItems().map((i) => i.textContent)).toEqual([
221
+ 'Details',
222
+ 'Classification',
223
+ 'Bibliographic',
224
+ 'Abstract',
225
+ ])
226
+ })
227
+
228
+ it('labels the overflow trigger for assistive technology', () => {
229
+ layout.row = 1600
230
+ render()
231
+
232
+ expect(trigger()?.getAttribute('aria-label')).toBe('All tabs')
233
+ })
234
+
235
+ it('selects a tab chosen from the overflow menu', () => {
236
+ layout.row = 1600
237
+ render()
238
+
239
+ act(() => {
240
+ menuItems()[2].click()
241
+ })
242
+
243
+ expect(onChange).toHaveBeenCalledWith('bibliographic')
244
+ })
245
+
246
+ it('keeps the overflow trigger outside the tablist', () => {
247
+ layout.row = 1600
248
+ render()
249
+
250
+ expect(trigger()?.closest('[role="tablist"]')).toBeNull()
251
+ })
252
+
253
+ /**
254
+ * The roving tabindex covers the tablist only. The overflow trigger is a
255
+ * sibling control, not a tab, so it keeps its own place in the tab order —
256
+ * folding it into the roving set would make it unreachable whenever the
257
+ * active tab is not adjacent to it.
258
+ */
259
+ it('leaves the overflow trigger independently reachable from the keyboard', () => {
260
+ layout.row = 1600
261
+ render()
262
+
263
+ expect(trigger()?.tabIndex).not.toBe(-1)
264
+ })
265
+
266
+ it('gives the tablist a single tab stop', () => {
267
+ render({ activeTab: 'classification' })
268
+
269
+ const focusable = tabButtons().filter((b) => b.tabIndex === 0)
270
+ expect(focusable.map((b) => b.textContent)).toEqual(['Classification'])
271
+ })
272
+
273
+ it('moves selection to the next tab on ArrowRight', () => {
274
+ render({ activeTab: 'classification' })
275
+
276
+ act(() => {
277
+ tabButtons()[1].dispatchEvent(
278
+ new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
279
+ )
280
+ })
281
+
282
+ expect(onChange).toHaveBeenCalledWith('bibliographic')
283
+ })
284
+
285
+ it('moves selection to the previous tab on ArrowLeft', () => {
286
+ render({ activeTab: 'classification' })
287
+
288
+ act(() => {
289
+ tabButtons()[1].dispatchEvent(
290
+ new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
291
+ )
292
+ })
293
+
294
+ expect(onChange).toHaveBeenCalledWith('details')
295
+ })
296
+
297
+ it('wraps from the first tab to the last on ArrowLeft', () => {
298
+ render({ activeTab: 'details' })
299
+
300
+ act(() => {
301
+ tabButtons()[0].dispatchEvent(
302
+ new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
303
+ )
304
+ })
305
+
306
+ expect(onChange).toHaveBeenCalledWith('abstract')
307
+ })
308
+
309
+ it('jumps to the first tab on Home', () => {
310
+ render({ activeTab: 'bibliographic' })
311
+
312
+ act(() => {
313
+ tabButtons()[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }))
314
+ })
315
+
316
+ expect(onChange).toHaveBeenCalledWith('details')
317
+ })
318
+
319
+ it('jumps to the last tab on End', () => {
320
+ render({ activeTab: 'details' })
321
+
322
+ act(() => {
323
+ tabButtons()[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }))
324
+ })
325
+
326
+ expect(onChange).toHaveBeenCalledWith('abstract')
327
+ })
328
+
329
+ it('associates each tab with its panel', () => {
330
+ render()
331
+
332
+ expect(tabButtons()[1].getAttribute('aria-controls')).toBe('tabset:main-panel-classification')
333
+ expect(tabButtons()[1].id).toBe('tabset:main-tab-classification')
334
+ })
335
+
336
+ it('shows the error badge for a tab that has errors', () => {
337
+ render({ errorCounts: { bibliographic: 2 } })
338
+
339
+ expect(tabButtons()[2].textContent).toBe('Bibliographic2')
340
+ })
341
+
342
+ it('re-measures when the observed elements resize', () => {
343
+ layout.row = 400
344
+ render()
345
+ expect(trigger()).toBeNull()
346
+
347
+ layout.row = 1600
348
+ act(() => {
349
+ for (const cb of resizeCallbacks) cb([], {} as ResizeObserver)
350
+ })
351
+
352
+ expect(trigger()).not.toBeNull()
353
+ })
354
+
355
+ /**
356
+ * Both the container and the tab row are observed. A label change, a badge
357
+ * appearing, or a conditional tab unmounting alters the row's width without
358
+ * the container's changing at all, so watching only the container would miss
359
+ * every content-driven overflow change.
360
+ */
361
+ it('observes the tab row as well as the container', () => {
362
+ render()
363
+
364
+ const observedClasses = observed.map((el) => el.className)
365
+ expect(observedClasses.some((c) => c.includes('byline-admin-tabs-viewport'))).toBe(false)
366
+ expect(observedClasses.some((c) => c.includes('byline-admin-tablist'))).toBe(true)
367
+ expect(observedClasses.some((c) => c.includes('byline-admin-tabs '))).toBe(true)
368
+ })
369
+
370
+ // ─── Drag-to-scroll (desktop) ─────────────────────────────────────────
371
+
372
+ const viewport = () =>
373
+ container.querySelector<HTMLElement>('.byline-admin-tabs-viewport') as HTMLElement
374
+
375
+ const drag = (from: number, to: number, pointerType = 'mouse') => {
376
+ const vp = viewport()
377
+ act(() => {
378
+ vp.dispatchEvent(pointerEvent('pointerdown', { clientX: from, pointerType }))
379
+ vp.dispatchEvent(pointerEvent('pointermove', { clientX: to, pointerType }))
380
+ vp.dispatchEvent(pointerEvent('pointerup', { clientX: to, pointerType }))
381
+ })
382
+ }
383
+
384
+ it('scrolls the strip when dragged with the mouse', () => {
385
+ layout.row = 1600
386
+ render()
387
+ viewport().scrollLeft = 100
388
+
389
+ drag(300, 240)
390
+
391
+ expect(viewport().scrollLeft).toBe(160)
392
+ })
393
+
394
+ it('leaves touch dragging to the browser', () => {
395
+ layout.row = 1600
396
+ render()
397
+ viewport().scrollLeft = 100
398
+
399
+ drag(300, 240, 'touch')
400
+
401
+ expect(viewport().scrollLeft).toBe(100)
402
+ })
403
+
404
+ it('does not drag a strip that has no overflow', () => {
405
+ layout.row = 400
406
+ render()
407
+ viewport().scrollLeft = 0
408
+
409
+ drag(300, 240)
410
+
411
+ expect(viewport().scrollLeft).toBe(0)
412
+ })
413
+
414
+ /**
415
+ * The whole reason for the movement threshold: a plain click must still
416
+ * select its tab.
417
+ */
418
+ it('still selects a tab on a click that never became a drag', () => {
419
+ layout.row = 1600
420
+ render()
421
+ const vp = viewport()
422
+
423
+ act(() => {
424
+ vp.dispatchEvent(pointerEvent('pointerdown', { clientX: 300 }))
425
+ vp.dispatchEvent(pointerEvent('pointermove', { clientX: 302 }))
426
+ vp.dispatchEvent(pointerEvent('pointerup', { clientX: 302 }))
427
+ tabButtons()[2].click()
428
+ })
429
+
430
+ expect(onChange).toHaveBeenCalledWith('bibliographic')
431
+ })
432
+
433
+ it('does not select a tab on the click that ends a drag', () => {
434
+ layout.row = 1600
435
+ render()
436
+
437
+ drag(300, 240)
438
+ act(() => {
439
+ tabButtons()[2].click()
440
+ })
441
+
442
+ expect(onChange).not.toHaveBeenCalled()
443
+ })
444
+
445
+ /**
446
+ * The case that a consume-once flag gets wrong. A drag released outside the
447
+ * strip produces no click at all, so nothing arrives to clear the flag, and
448
+ * the reader's next deliberate click on a tab would be swallowed instead.
449
+ */
450
+ it('selects normally on a later click when the drag produced no click', () => {
451
+ layout.row = 1600
452
+ render()
453
+
454
+ drag(300, 240)
455
+
456
+ // No click followed the drag. Time passes, then the reader clicks a tab.
457
+ const now = performance.now()
458
+ const clock = vi.spyOn(performance, 'now').mockReturnValue(now + 5000)
459
+ act(() => {
460
+ tabButtons()[2].click()
461
+ })
462
+ clock.mockRestore()
463
+
464
+ expect(onChange).toHaveBeenCalledWith('bibliographic')
465
+ })
466
+
467
+ /** The suppression is one-shot — the next click must select normally. */
468
+ it('selects again on the click after the drag-ending one', () => {
469
+ layout.row = 1600
470
+ render()
471
+
472
+ drag(300, 240)
473
+ act(() => {
474
+ tabButtons()[2].click()
475
+ tabButtons()[1].click()
476
+ })
477
+
478
+ expect(onChange).toHaveBeenCalledTimes(1)
479
+ expect(onChange).toHaveBeenCalledWith('classification')
480
+ })
481
+ })