@byline/admin 6.1.0 → 6.2.1
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/dist/forms/form-layout-styles.test.node.d.ts +8 -0
- package/dist/forms/form-layout.d.ts +39 -0
- package/dist/forms/form-layout.js +100 -0
- package/dist/forms/form-page-chrome.d.ts +110 -0
- package/dist/forms/form-page-chrome.js +253 -0
- package/dist/forms/form-renderer.d.ts +2 -18
- package/dist/forms/form-renderer.js +104 -418
- package/dist/forms/form-renderer.module.js +2 -0
- package/dist/forms/form-renderer_module.css +13 -0
- package/dist/forms/use-form-submission.d.ts +74 -0
- package/dist/forms/use-form-submission.js +166 -0
- package/dist/forms/use-form-tabs.d.ts +29 -0
- package/dist/forms/use-form-tabs.js +45 -0
- package/dist/presentation/tab-strip-drag.d.ts +75 -0
- package/dist/presentation/tab-strip-drag.js +25 -0
- package/dist/presentation/tab-strip-drag.test.node.d.ts +8 -0
- package/dist/presentation/tab-strip-geometry.d.ts +75 -0
- package/dist/presentation/tab-strip-geometry.js +36 -0
- package/dist/presentation/tab-strip-geometry.test.node.d.ts +8 -0
- package/dist/presentation/tabs.d.ts +30 -7
- package/dist/presentation/tabs.js +260 -29
- package/dist/presentation/tabs.module.js +11 -1
- package/dist/presentation/tabs_module.css +166 -5
- package/package.json +7 -7
- package/src/forms/form-layout-styles.test.node.ts +32 -0
- package/src/forms/form-layout.test.tsx +276 -0
- package/src/forms/form-layout.tsx +198 -0
- package/src/forms/form-page-chrome.tsx +460 -0
- package/src/forms/form-renderer-submit.test.tsx +40 -0
- package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
- package/src/forms/form-renderer.module.css +18 -0
- package/src/forms/form-renderer.tsx +115 -593
- package/src/forms/use-form-submission.test.tsx +564 -0
- package/src/forms/use-form-submission.ts +293 -0
- package/src/forms/use-form-tabs.ts +89 -0
- package/src/presentation/tab-strip-drag.test.node.ts +143 -0
- package/src/presentation/tab-strip-drag.ts +114 -0
- package/src/presentation/tab-strip-geometry.test.node.ts +254 -0
- package/src/presentation/tab-strip-geometry.ts +158 -0
- package/src/presentation/tabs.module.css +209 -11
- package/src/presentation/tabs.test.tsx +481 -0
- package/src/presentation/tabs.tsx +369 -34
- package/dist/exports-parity.test.node.js +0 -37
- package/dist/fields/array/array-field.jsx +0 -209
- package/dist/fields/blocks/blocks-field.jsx +0 -248
- package/dist/fields/checkbox/checkbox-field.jsx +0 -24
- package/dist/fields/code/code-editor.jsx +0 -211
- package/dist/fields/code/code-field.jsx +0 -109
- package/dist/fields/column-formatter.jsx +0 -25
- package/dist/fields/date-time-formatter.jsx +0 -18
- package/dist/fields/datetime/datetime-field.jsx +0 -22
- package/dist/fields/draggable-context-menu.jsx +0 -50
- package/dist/fields/field-admin.test.node.js +0 -40
- package/dist/fields/field-renderer.jsx +0 -136
- package/dist/fields/field-services-context.jsx +0 -17
- package/dist/fields/file/file-field.jsx +0 -176
- package/dist/fields/file/file-upload-field.jsx +0 -89
- package/dist/fields/group/group-field.jsx +0 -46
- package/dist/fields/image/image-field.jsx +0 -183
- package/dist/fields/image/image-upload-field.jsx +0 -157
- package/dist/fields/local-date-time.jsx +0 -69
- package/dist/fields/locale-badge.jsx +0 -19
- package/dist/fields/numerical/numerical-field.jsx +0 -93
- package/dist/fields/relation/relation-column-formatter.jsx +0 -74
- package/dist/fields/relation/relation-display.jsx +0 -118
- package/dist/fields/relation/relation-field.jsx +0 -108
- package/dist/fields/relation/relation-many-field.jsx +0 -142
- package/dist/fields/relation/relation-picker.jsx +0 -240
- package/dist/fields/relation/relation-summary.jsx +0 -45
- package/dist/fields/select/select-field.jsx +0 -27
- package/dist/fields/sortable-item.jsx +0 -54
- package/dist/fields/text/text-field.jsx +0 -79
- package/dist/fields/text-area/text-area-field.jsx +0 -79
- package/dist/forms/available-locales-reconcile.test.node.js +0 -43
- package/dist/forms/available-locales-widget.jsx +0 -63
- package/dist/forms/document-actions.jsx +0 -486
- package/dist/forms/form-context.jsx +0 -598
- package/dist/forms/form-modals.jsx +0 -128
- package/dist/forms/form-renderer.jsx +0 -532
- package/dist/forms/form-status-display.jsx +0 -69
- package/dist/forms/navigation-guard.jsx +0 -68
- package/dist/forms/nested-path.test.node.js +0 -115
- package/dist/forms/path-widget.jsx +0 -125
- package/dist/forms/pending-uploads.test.node.js +0 -19
- package/dist/forms/repeating-items.test.node.js +0 -29
- package/dist/forms/scheduled-publication-control.jsx +0 -389
- package/dist/forms/scheduled-publication-state.test.node.js +0 -121
- package/dist/forms/scheduled-publication-time.test.node.js +0 -70
- package/dist/forms/status-transitions.test.node.js +0 -76
- package/dist/forms/tree-placement-widget.jsx +0 -160
- package/dist/forms/upload-executor.test.node.js +0 -368
- package/dist/forms/use-form-layout.test.node.js +0 -69
- package/dist/modules/admin-account/components/change-password.jsx +0 -169
- package/dist/modules/admin-account/components/container.jsx +0 -164
- package/dist/modules/admin-account/components/preferences.jsx +0 -148
- package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
- package/dist/modules/admin-account/components/update.jsx +0 -186
- package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
- package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
- package/dist/modules/admin-roles/components/create.jsx +0 -155
- package/dist/modules/admin-roles/components/permissions.jsx +0 -239
- package/dist/modules/admin-roles/components/update.jsx +0 -148
- package/dist/modules/admin-users/components/create.jsx +0 -206
- package/dist/modules/admin-users/components/roles.jsx +0 -126
- package/dist/modules/admin-users/components/set-password.jsx +0 -133
- package/dist/modules/admin-users/components/update.jsx +0 -215
- package/dist/modules/analytics/components/dashboard.jsx +0 -208
- package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
- package/dist/modules/analytics/components/timeseries.jsx +0 -193
- package/dist/modules/auth/components/sign-in-form.jsx +0 -89
- package/dist/modules/auth/safe-redirect.test.node.js +0 -40
- package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
- package/dist/presentation/group.jsx +0 -26
- package/dist/presentation/row.jsx +0 -23
- package/dist/presentation/tabs.jsx +0 -39
- package/dist/services/admin-services-context.jsx +0 -17
- package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
- 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
|
+
})
|