@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.
Files changed (119) hide show
  1. package/dist/forms/form-layout-styles.test.node.d.ts +8 -0
  2. package/dist/forms/form-layout.d.ts +39 -0
  3. package/dist/forms/form-layout.js +100 -0
  4. package/dist/forms/form-page-chrome.d.ts +110 -0
  5. package/dist/forms/form-page-chrome.js +253 -0
  6. package/dist/forms/form-renderer.d.ts +2 -18
  7. package/dist/forms/form-renderer.js +104 -418
  8. package/dist/forms/form-renderer.module.js +2 -0
  9. package/dist/forms/form-renderer_module.css +13 -0
  10. package/dist/forms/use-form-submission.d.ts +74 -0
  11. package/dist/forms/use-form-submission.js +166 -0
  12. package/dist/forms/use-form-tabs.d.ts +29 -0
  13. package/dist/forms/use-form-tabs.js +45 -0
  14. package/dist/presentation/tab-strip-drag.d.ts +75 -0
  15. package/dist/presentation/tab-strip-drag.js +25 -0
  16. package/dist/presentation/tab-strip-drag.test.node.d.ts +8 -0
  17. package/dist/presentation/tab-strip-geometry.d.ts +75 -0
  18. package/dist/presentation/tab-strip-geometry.js +36 -0
  19. package/dist/presentation/tab-strip-geometry.test.node.d.ts +8 -0
  20. package/dist/presentation/tabs.d.ts +30 -7
  21. package/dist/presentation/tabs.js +260 -29
  22. package/dist/presentation/tabs.module.js +11 -1
  23. package/dist/presentation/tabs_module.css +166 -5
  24. package/package.json +7 -7
  25. package/src/forms/form-layout-styles.test.node.ts +32 -0
  26. package/src/forms/form-layout.test.tsx +276 -0
  27. package/src/forms/form-layout.tsx +198 -0
  28. package/src/forms/form-page-chrome.tsx +460 -0
  29. package/src/forms/form-renderer-submit.test.tsx +40 -0
  30. package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
  31. package/src/forms/form-renderer.module.css +18 -0
  32. package/src/forms/form-renderer.tsx +115 -593
  33. package/src/forms/use-form-submission.test.tsx +564 -0
  34. package/src/forms/use-form-submission.ts +293 -0
  35. package/src/forms/use-form-tabs.ts +89 -0
  36. package/src/presentation/tab-strip-drag.test.node.ts +143 -0
  37. package/src/presentation/tab-strip-drag.ts +114 -0
  38. package/src/presentation/tab-strip-geometry.test.node.ts +254 -0
  39. package/src/presentation/tab-strip-geometry.ts +158 -0
  40. package/src/presentation/tabs.module.css +209 -11
  41. package/src/presentation/tabs.test.tsx +481 -0
  42. package/src/presentation/tabs.tsx +369 -34
  43. package/dist/exports-parity.test.node.js +0 -37
  44. package/dist/fields/array/array-field.jsx +0 -209
  45. package/dist/fields/blocks/blocks-field.jsx +0 -248
  46. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  47. package/dist/fields/code/code-editor.jsx +0 -211
  48. package/dist/fields/code/code-field.jsx +0 -109
  49. package/dist/fields/column-formatter.jsx +0 -25
  50. package/dist/fields/date-time-formatter.jsx +0 -18
  51. package/dist/fields/datetime/datetime-field.jsx +0 -22
  52. package/dist/fields/draggable-context-menu.jsx +0 -50
  53. package/dist/fields/field-admin.test.node.js +0 -40
  54. package/dist/fields/field-renderer.jsx +0 -136
  55. package/dist/fields/field-services-context.jsx +0 -17
  56. package/dist/fields/file/file-field.jsx +0 -176
  57. package/dist/fields/file/file-upload-field.jsx +0 -89
  58. package/dist/fields/group/group-field.jsx +0 -46
  59. package/dist/fields/image/image-field.jsx +0 -183
  60. package/dist/fields/image/image-upload-field.jsx +0 -157
  61. package/dist/fields/local-date-time.jsx +0 -69
  62. package/dist/fields/locale-badge.jsx +0 -19
  63. package/dist/fields/numerical/numerical-field.jsx +0 -93
  64. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  65. package/dist/fields/relation/relation-display.jsx +0 -118
  66. package/dist/fields/relation/relation-field.jsx +0 -108
  67. package/dist/fields/relation/relation-many-field.jsx +0 -142
  68. package/dist/fields/relation/relation-picker.jsx +0 -240
  69. package/dist/fields/relation/relation-summary.jsx +0 -45
  70. package/dist/fields/select/select-field.jsx +0 -27
  71. package/dist/fields/sortable-item.jsx +0 -54
  72. package/dist/fields/text/text-field.jsx +0 -79
  73. package/dist/fields/text-area/text-area-field.jsx +0 -79
  74. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  75. package/dist/forms/available-locales-widget.jsx +0 -63
  76. package/dist/forms/document-actions.jsx +0 -486
  77. package/dist/forms/form-context.jsx +0 -598
  78. package/dist/forms/form-modals.jsx +0 -128
  79. package/dist/forms/form-renderer.jsx +0 -532
  80. package/dist/forms/form-status-display.jsx +0 -69
  81. package/dist/forms/navigation-guard.jsx +0 -68
  82. package/dist/forms/nested-path.test.node.js +0 -115
  83. package/dist/forms/path-widget.jsx +0 -125
  84. package/dist/forms/pending-uploads.test.node.js +0 -19
  85. package/dist/forms/repeating-items.test.node.js +0 -29
  86. package/dist/forms/scheduled-publication-control.jsx +0 -389
  87. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  88. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  89. package/dist/forms/status-transitions.test.node.js +0 -76
  90. package/dist/forms/tree-placement-widget.jsx +0 -160
  91. package/dist/forms/upload-executor.test.node.js +0 -368
  92. package/dist/forms/use-form-layout.test.node.js +0 -69
  93. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  94. package/dist/modules/admin-account/components/container.jsx +0 -164
  95. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  96. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  97. package/dist/modules/admin-account/components/update.jsx +0 -186
  98. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  99. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  100. package/dist/modules/admin-roles/components/create.jsx +0 -155
  101. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  102. package/dist/modules/admin-roles/components/update.jsx +0 -148
  103. package/dist/modules/admin-users/components/create.jsx +0 -206
  104. package/dist/modules/admin-users/components/roles.jsx +0 -126
  105. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  106. package/dist/modules/admin-users/components/update.jsx +0 -215
  107. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  108. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  109. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  110. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  111. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  112. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  113. package/dist/presentation/group.jsx +0 -26
  114. package/dist/presentation/row.jsx +0 -23
  115. package/dist/presentation/tabs.jsx +0 -39
  116. package/dist/services/admin-services-context.jsx +0 -17
  117. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  118. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  119. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -0,0 +1,293 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useCallback, useRef, useState } from 'react'
12
+
13
+ import type { Field } from '@byline/core'
14
+ import type { DocumentPatch } from '@byline/core/patches'
15
+ import { useTranslation } from '@byline/i18n/react'
16
+
17
+ import { useBylineFieldServices } from '../fields/field-services-context'
18
+ import { useFormContext } from './form-context'
19
+ import { executeUploadsWithProgress } from './upload-executor'
20
+
21
+ /**
22
+ * Payload emitted by the form on Save. Carries the content (field data +
23
+ * patches) alongside the document-grain system fields (path / advertised
24
+ * locales) and per-bucket dirty flags so the host can route each piece to the
25
+ * right write path — versioned for content, immediate/non-versioned for the
26
+ * system fields. See docs/08-internationalization/index.md.
27
+ */
28
+ export interface SystemFieldsSubmitPayload {
29
+ // biome-ignore lint/suspicious/noExplicitAny: data is collection-specific
30
+ data: any
31
+ patches: DocumentPatch[]
32
+ contentDirty: boolean
33
+ pathDirty: boolean
34
+ systemPath?: string | null
35
+ availableLocalesDirty: boolean
36
+ systemAvailableLocales?: string[]
37
+ }
38
+
39
+ /**
40
+ * Where a save has got to. Mutually exclusive by construction: the form cannot
41
+ * be validating and uploading at once, which two independent booleans allowed.
42
+ */
43
+ export type SubmissionPhase =
44
+ | { kind: 'idle' }
45
+ | { kind: 'validating' }
46
+ | { kind: 'uploading' }
47
+ | { kind: 'confirmingSystemFields'; payload: SystemFieldsSubmitPayload }
48
+ | { kind: 'submitting' }
49
+
50
+ export interface UseFormSubmissionOptions {
51
+ mode: 'create' | 'edit'
52
+ fields: Field[]
53
+ /** Persisted document id (edit mode only); feeds server-side upload hooks. */
54
+ documentId?: string
55
+ /**
56
+ * Only collections that opted into the advertised-locale widget emit the
57
+ * locale set, so the write path never touches
58
+ * `byline_document_available_locales` for the others.
59
+ */
60
+ advertiseLocales?: boolean
61
+ onSubmit: (payload: SystemFieldsSubmitPayload) => void | Promise<void>
62
+ /** Mutations blocked (stale/lock/discarding). Read synchronously, per call. */
63
+ isBlocked: () => boolean
64
+ /**
65
+ * Called immediately before the form becomes `inert` — once before uploads
66
+ * and once before submission — so the caller can record the focused element
67
+ * and restore it when busy ends. The DOM refs stay in the component; the
68
+ * hook only signals the transition.
69
+ */
70
+ onBeforeBusy?: () => void
71
+ }
72
+
73
+ export interface UseFormSubmissionResult {
74
+ phase: SubmissionPhase
75
+ isBusy: boolean
76
+ submit: () => Promise<void>
77
+ confirmSystemFields: () => Promise<void>
78
+ cancelSystemFields: () => void
79
+ }
80
+
81
+ const IDLE: SubmissionPhase = { kind: 'idle' }
82
+
83
+ /**
84
+ * Owns one save: validate → upload → (confirm) → submit.
85
+ *
86
+ * Admission is decided by a ref, not by `phase`. Rendered state is not a
87
+ * re-entry guard: two `submit()` calls in the same turn both observe
88
+ * `phase.kind === 'idle'` because React has not rerendered between them. The
89
+ * ref is set synchronously at the top of `submit()` and cleared in `finally`,
90
+ * spanning validation through submission — the whole window, not just the
91
+ * final request. `phase` drives the UI; the ref decides admission.
92
+ */
93
+ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSubmissionResult {
94
+ const { mode, fields, documentId, advertiseLocales, onSubmit, isBlocked, onBeforeBusy } = options
95
+
96
+ const {
97
+ getFieldValues,
98
+ runFieldHooks,
99
+ validateForm,
100
+ resetHasChanges,
101
+ getPatches,
102
+ getDirtyBreakdown,
103
+ getSystemPath,
104
+ getSystemAvailableLocales,
105
+ setFieldValue,
106
+ setFieldError,
107
+ getPendingUploads,
108
+ clearPendingUploads,
109
+ setFieldUploading,
110
+ } = useFormContext()
111
+ const { t } = useTranslation('byline-admin')
112
+ const { uploadField } = useBylineFieldServices()
113
+
114
+ const [phase, setPhase] = useState<SubmissionPhase>(IDLE)
115
+ const inFlightRef = useRef(false)
116
+
117
+ const isBusy = phase.kind === 'uploading' || phase.kind === 'submitting'
118
+
119
+ // Await the host handler. Resolution means the save succeeded and the clean
120
+ // baseline can be committed; rejection preserves dirty state so the editor
121
+ // does not lose work and the navigation guard keeps blocking. Host handlers
122
+ // that surface their own toast MUST rethrow afterwards.
123
+ const deliver = useCallback(
124
+ async (payload: SystemFieldsSubmitPayload) => {
125
+ // Settle before returning. `runUploads` may have left the phase at
126
+ // `uploading`, and this is the only reset on the delivery path: returning
127
+ // without it strands the form busy and inert with nothing in flight.
128
+ // Dirty state is deliberately untouched, so the save can be retried once
129
+ // the block clears.
130
+ if (isBlocked() || typeof onSubmit !== 'function') {
131
+ setPhase(IDLE)
132
+ return
133
+ }
134
+ onBeforeBusy?.()
135
+ setPhase({ kind: 'submitting' })
136
+ try {
137
+ await onSubmit(payload)
138
+ resetHasChanges()
139
+ } catch {
140
+ // Intentionally swallowed here — the host has already reported the
141
+ // failure to the user. Dirty state is preserved by not resetting.
142
+ } finally {
143
+ setPhase(IDLE)
144
+ }
145
+ },
146
+ [isBlocked, onBeforeBusy, onSubmit, resetHasChanges]
147
+ )
148
+
149
+ const runUploads = useCallback(async (): Promise<boolean> => {
150
+ const pendingUploads = getPendingUploads()
151
+ if (pendingUploads.size === 0) return true
152
+
153
+ onBeforeBusy?.()
154
+ setPhase({ kind: 'uploading' })
155
+ try {
156
+ const uploadResult = await executeUploadsWithProgress(
157
+ pendingUploads,
158
+ uploadField,
159
+ ({ fieldPath, status }) => {
160
+ setFieldUploading(fieldPath, status === 'uploading')
161
+ },
162
+ {
163
+ // Document context for server-side upload hooks: the persisted
164
+ // document id (edit mode only) plus any `upload.context` form
165
+ // values declared on the schema field. See UploadConfig.context
166
+ // in @byline/core.
167
+ documentId: mode === 'edit' ? documentId : undefined,
168
+ fields,
169
+ getFormValues: getFieldValues,
170
+ }
171
+ )
172
+
173
+ if (!uploadResult.allSucceeded) {
174
+ for (const [fieldPath, errorMessage] of uploadResult.errors.entries()) {
175
+ setFieldError(fieldPath, t('forms.uploadFailedFieldError', { message: errorMessage }))
176
+ }
177
+ console.error('One or more uploads failed:', uploadResult.errors)
178
+ return false
179
+ }
180
+
181
+ // Replace pending StoredFileValues with real ones in form data
182
+ for (const [fieldPath, storedFile] of uploadResult.successful.entries()) {
183
+ setFieldValue(fieldPath, storedFile)
184
+ }
185
+
186
+ // Clear pending uploads (blob URLs already revoked by clearPendingUploads)
187
+ clearPendingUploads()
188
+ return true
189
+ } catch (err) {
190
+ console.error('Upload execution error:', err)
191
+ return false
192
+ }
193
+ }, [
194
+ clearPendingUploads,
195
+ documentId,
196
+ fields,
197
+ getFieldValues,
198
+ getPendingUploads,
199
+ mode,
200
+ onBeforeBusy,
201
+ setFieldError,
202
+ setFieldUploading,
203
+ setFieldValue,
204
+ t,
205
+ uploadField,
206
+ ])
207
+
208
+ const buildPayload = useCallback((): { payload: SystemFieldsSubmitPayload; reason: string } => {
209
+ const { contentDirty, pathDirty, availableLocalesDirty, reason } = getDirtyBreakdown()
210
+ return {
211
+ reason,
212
+ payload: {
213
+ data: getFieldValues(),
214
+ patches: getPatches(),
215
+ contentDirty,
216
+ pathDirty,
217
+ systemPath: getSystemPath(),
218
+ availableLocalesDirty,
219
+ systemAvailableLocales: advertiseLocales ? getSystemAvailableLocales() : undefined,
220
+ },
221
+ }
222
+ }, [
223
+ advertiseLocales,
224
+ getDirtyBreakdown,
225
+ getFieldValues,
226
+ getPatches,
227
+ getSystemAvailableLocales,
228
+ getSystemPath,
229
+ ])
230
+
231
+ const submit = useCallback(async () => {
232
+ if (isBlocked()) return
233
+ if (inFlightRef.current) return
234
+ inFlightRef.current = true
235
+ try {
236
+ setPhase({ kind: 'validating' })
237
+
238
+ // Run field-level beforeValidate hooks (submit-time), then validate
239
+ const hookErrors = await runFieldHooks(fields)
240
+ const formErrors = validateForm(fields)
241
+ const allErrors = [...hookErrors, ...formErrors]
242
+
243
+ if (allErrors.length > 0) {
244
+ console.error('Form validation failed:', allErrors)
245
+ setPhase(IDLE)
246
+ return
247
+ }
248
+
249
+ if (isBlocked()) {
250
+ setPhase(IDLE)
251
+ return
252
+ }
253
+
254
+ if (!(await runUploads())) {
255
+ setPhase(IDLE)
256
+ return
257
+ }
258
+
259
+ const { payload, reason } = buildPayload()
260
+
261
+ // Editing the document-grain system fields (path / advertised locales) is
262
+ // an immediate, non-versioned write that does NOT reset workflow status,
263
+ // so confirm it before saving. Create mode writes everything as part of
264
+ // the initial version, so no confirmation applies there.
265
+ if (mode === 'edit' && (reason === 'direct-write' || reason === 'both')) {
266
+ setPhase({ kind: 'confirmingSystemFields', payload })
267
+ return
268
+ }
269
+
270
+ await deliver(payload)
271
+ } finally {
272
+ inFlightRef.current = false
273
+ }
274
+ }, [buildPayload, deliver, fields, isBlocked, mode, runUploads, runFieldHooks, validateForm])
275
+
276
+ const confirmSystemFields = useCallback(async () => {
277
+ if (phase.kind !== 'confirmingSystemFields') return
278
+ if (inFlightRef.current) return
279
+ inFlightRef.current = true
280
+ const { payload } = phase
281
+ try {
282
+ await deliver(payload)
283
+ } finally {
284
+ inFlightRef.current = false
285
+ }
286
+ }, [deliver, phase])
287
+
288
+ const cancelSystemFields = useCallback(() => {
289
+ setPhase((current) => (current.kind === 'confirmingSystemFields' ? IDLE : current))
290
+ }, [])
291
+
292
+ return { phase, isBusy, submit, confirmSystemFields, cancelSystemFields }
293
+ }
@@ -0,0 +1,89 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useEffect, useMemo, useState } from 'react'
12
+
13
+ import type { TabDefinition, TabSetDefinition } from '@byline/core'
14
+
15
+ import { useFormContext } from './form-context'
16
+
17
+ /** One tab set, resolved against live form data and current errors. */
18
+ export interface ResolvedTabSet {
19
+ /** Tabs whose `condition` is satisfied, in declaration order. */
20
+ visibleTabs: TabDefinition[]
21
+ /** The tab that should render, after falling back off a hidden selection. */
22
+ activeTabName: string
23
+ activeTab: TabDefinition | undefined
24
+ /** Error count per tab name, for the tab bar's badges. */
25
+ errorCounts: Record<string, number> | undefined
26
+ }
27
+
28
+ export interface UseFormTabsResult {
29
+ resolve: (set: TabSetDefinition) => ResolvedTabSet
30
+ errorCountsBySet: Record<string, Record<string, number>>
31
+ }
32
+
33
+ /**
34
+ * Tab visibility, per-tab error counts, and fallback when the selected tab is
35
+ * hidden.
36
+ *
37
+ * It deliberately does **not** own the selection. `FormRenderer` lifts that
38
+ * above the keyed `FormProvider` so the editor's tab choices survive the
39
+ * remount a locale change triggers; a hook living inside that subtree would
40
+ * lose them on every switch. The selection arrives as `activeTabBySet` and
41
+ * changes are reported upward.
42
+ */
43
+ export function useFormTabs(
44
+ fieldToTabPath: Map<string, { tabSetName: string; tabName: string }>,
45
+ activeTabBySet: Record<string, string>
46
+ ): UseFormTabsResult {
47
+ const { errors: initialErrors, subscribeErrors, subscribeMeta, getFieldValues } = useFormContext()
48
+
49
+ const [errors, setErrors] = useState(initialErrors)
50
+ useEffect(() => {
51
+ return subscribeErrors((newErrors) => setErrors(newErrors))
52
+ }, [subscribeErrors])
53
+
54
+ // Live form data, so TabDefinition.condition re-evaluates per keystroke.
55
+ const [formData, setFormData] = useState<Record<string, any>>(() => getFieldValues())
56
+ useEffect(() => {
57
+ return subscribeMeta(() => setFormData(getFieldValues()))
58
+ }, [subscribeMeta, getFieldValues])
59
+
60
+ // Per-tab-set error counts: { [tabSetName]: { [tabName]: count } }.
61
+ // Each tab bar consumes its own slice.
62
+ const errorCountsBySet = useMemo<Record<string, Record<string, number>>>(() => {
63
+ const result: Record<string, Record<string, number>> = {}
64
+ for (const err of errors) {
65
+ const path = fieldToTabPath.get(err.field)
66
+ if (!path) continue
67
+ result[path.tabSetName] ??= {}
68
+ result[path.tabSetName]![path.tabName] = (result[path.tabSetName]?.[path.tabName] ?? 0) + 1
69
+ }
70
+ return result
71
+ }, [errors, fieldToTabPath])
72
+
73
+ const resolve = (set: TabSetDefinition): ResolvedTabSet => {
74
+ const visibleTabs = set.tabs.filter((tab) => !tab.condition || tab.condition(formData))
75
+ const requested = activeTabBySet[set.name] ?? ''
76
+ const activeTabName =
77
+ visibleTabs.length > 0 && !visibleTabs.some((t) => t.name === requested)
78
+ ? (visibleTabs[0]?.name ?? requested)
79
+ : requested
80
+ return {
81
+ visibleTabs,
82
+ activeTabName,
83
+ activeTab: visibleTabs.find((t) => t.name === activeTabName),
84
+ errorCounts: errorCountsBySet[set.name],
85
+ }
86
+ }
87
+
88
+ return { resolve, errorCountsBySet }
89
+ }
@@ -0,0 +1,143 @@
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 { describe, expect, it } from 'vitest'
10
+
11
+ import {
12
+ advanceDrag,
13
+ CLICK_SUPPRESSION_MS,
14
+ DRAG_THRESHOLD_PX,
15
+ shouldStartDrag,
16
+ shouldSuppressClick,
17
+ } from './tab-strip-drag'
18
+
19
+ describe('shouldStartDrag', () => {
20
+ const mouseDown = { pointerType: 'mouse', button: 0, overflowing: true }
21
+
22
+ it('starts on a primary mouse button over an overflowing strip', () => {
23
+ expect(shouldStartDrag(mouseDown)).toBe(true)
24
+ })
25
+
26
+ /**
27
+ * Touch already scrolls the viewport natively, with momentum and rubber-band
28
+ * behaviour no hand-rolled drag reproduces. Intercepting it would replace a
29
+ * good interaction with a worse one.
30
+ */
31
+ it('leaves touch to the browser', () => {
32
+ expect(shouldStartDrag({ ...mouseDown, pointerType: 'touch' })).toBe(false)
33
+ })
34
+
35
+ it('leaves pen to the browser', () => {
36
+ expect(shouldStartDrag({ ...mouseDown, pointerType: 'pen' })).toBe(false)
37
+ })
38
+
39
+ it('ignores the middle button', () => {
40
+ expect(shouldStartDrag({ ...mouseDown, button: 1 })).toBe(false)
41
+ })
42
+
43
+ it('ignores the secondary button, which opens the context menu', () => {
44
+ expect(shouldStartDrag({ ...mouseDown, button: 2 })).toBe(false)
45
+ })
46
+
47
+ it('does nothing when the strip has no overflow to scroll', () => {
48
+ expect(shouldStartDrag({ ...mouseDown, overflowing: false })).toBe(false)
49
+ })
50
+ })
51
+
52
+ describe('advanceDrag', () => {
53
+ const origin = { pointerX: 200, scrollLeft: 100 }
54
+ const idle = { origin, dragging: false }
55
+ const maxScroll = 400
56
+
57
+ /**
58
+ * Below the threshold the gesture is still a click. Scrolling here would
59
+ * make every click on a tab nudge the strip, and suppressing the click that
60
+ * follows would stop tabs selecting at all.
61
+ */
62
+ it('does not scroll before the threshold is crossed', () => {
63
+ const next = advanceDrag(idle, 200 + (DRAG_THRESHOLD_PX - 1), maxScroll)
64
+
65
+ expect(next).toEqual({ dragging: false, scrollLeft: null })
66
+ })
67
+
68
+ it('begins dragging once the pointer reaches the threshold', () => {
69
+ const next = advanceDrag(idle, 200 + DRAG_THRESHOLD_PX, maxScroll)
70
+
71
+ expect(next.dragging).toBe(true)
72
+ })
73
+
74
+ it('crosses the threshold in either direction', () => {
75
+ const next = advanceDrag(idle, 200 - DRAG_THRESHOLD_PX, maxScroll)
76
+
77
+ expect(next.dragging).toBe(true)
78
+ })
79
+
80
+ /** Dragging right pulls the content right, so the scroll offset decreases. */
81
+ it('scrolls the content with the pointer when dragged right', () => {
82
+ const next = advanceDrag({ origin, dragging: true }, 230, maxScroll)
83
+
84
+ expect(next.scrollLeft).toBe(70)
85
+ })
86
+
87
+ it('scrolls the content with the pointer when dragged left', () => {
88
+ const next = advanceDrag({ origin, dragging: true }, 170, maxScroll)
89
+
90
+ expect(next.scrollLeft).toBe(130)
91
+ })
92
+
93
+ it('clamps at the start of the strip', () => {
94
+ const next = advanceDrag({ origin, dragging: true }, 400, maxScroll)
95
+
96
+ expect(next.scrollLeft).toBe(0)
97
+ })
98
+
99
+ it('clamps at the end of the strip', () => {
100
+ const next = advanceDrag({ origin, dragging: true }, -400, maxScroll)
101
+
102
+ expect(next.scrollLeft).toBe(maxScroll)
103
+ })
104
+
105
+ /**
106
+ * A drag that wanders back past the threshold is still a drag — otherwise
107
+ * returning to the origin would hand the gesture back to the click handler
108
+ * and select whichever tab the pointer happened to end on.
109
+ */
110
+ it('stays a drag once it has started, even back within the threshold', () => {
111
+ const next = advanceDrag({ origin, dragging: true }, 201, maxScroll)
112
+
113
+ expect(next).toEqual({ dragging: true, scrollLeft: 99 })
114
+ })
115
+ })
116
+
117
+ describe('shouldSuppressClick', () => {
118
+ it('lets a click through when no drag has happened', () => {
119
+ expect(shouldSuppressClick(null, 1000)).toBe(false)
120
+ })
121
+
122
+ /** The click the browser synthesises at the end of a drag. */
123
+ it('suppresses the click that immediately follows a drag', () => {
124
+ expect(shouldSuppressClick(1000, 1000)).toBe(true)
125
+ })
126
+
127
+ it('suppresses a click just inside the window', () => {
128
+ expect(shouldSuppressClick(1000, 1000 + CLICK_SUPPRESSION_MS - 1)).toBe(true)
129
+ })
130
+
131
+ /**
132
+ * The suppression expires on its own rather than waiting to be consumed.
133
+ * A drag released outside the strip produces no click at all, and a flag
134
+ * left armed would swallow the reader's next deliberate click on a tab.
135
+ */
136
+ it('expires rather than waiting for a click that may never come', () => {
137
+ expect(shouldSuppressClick(1000, 1000 + CLICK_SUPPRESSION_MS)).toBe(false)
138
+ })
139
+
140
+ it('has long since expired by the time of a deliberate later click', () => {
141
+ expect(shouldSuppressClick(1000, 3000)).toBe(false)
142
+ })
143
+ })
@@ -0,0 +1,114 @@
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
+ /**
10
+ * Pure state for click-and-drag scrolling of the tab strip.
11
+ *
12
+ * The DOM plumbing — pointer capture, the click suppression, the cursor —
13
+ * lives in `tabs.tsx`; the rules it follows are here so they can be tested
14
+ * without a pointer.
15
+ */
16
+
17
+ /**
18
+ * How far the pointer must travel before the gesture stops being a click and
19
+ * becomes a drag.
20
+ *
21
+ * Without a threshold every click on a tab would nudge the strip by a pixel or
22
+ * two, and the click suppression that follows a drag would swallow the very
23
+ * clicks that select tabs.
24
+ */
25
+ export const DRAG_THRESHOLD_PX = 5
26
+
27
+ /**
28
+ * How long after a drag a click is treated as that drag's own click.
29
+ *
30
+ * A window rather than a consume-once flag, because the click is not
31
+ * guaranteed to arrive: a drag released outside the strip produces none, and
32
+ * a flag left armed would swallow the reader's next deliberate click on a tab.
33
+ * Far longer than the browser's synthetic click takes to arrive, far shorter
34
+ * than anyone can release and deliberately click again.
35
+ */
36
+ export const CLICK_SUPPRESSION_MS = 100
37
+
38
+ /** Where a gesture began. An internal part of `DragState`, not a public type. */
39
+ interface DragOrigin {
40
+ /** Pointer x at the moment the button went down. */
41
+ pointerX: number
42
+ /** The viewport's scroll offset at that same moment. */
43
+ scrollLeft: number
44
+ }
45
+
46
+ export interface DragState {
47
+ origin: DragOrigin
48
+ /** Whether the threshold has already been crossed. */
49
+ dragging: boolean
50
+ }
51
+
52
+ export interface DragAdvance {
53
+ dragging: boolean
54
+ /** The offset to scroll to, or `null` while the gesture is still a click. */
55
+ scrollLeft: number | null
56
+ }
57
+
58
+ /**
59
+ * Whether a `pointerdown` should begin a potential drag.
60
+ *
61
+ * Mouse only. Touch and pen already scroll the viewport natively, with
62
+ * momentum and rubber-banding that a hand-rolled drag does not reproduce, so
63
+ * taking the gesture over would trade a good interaction for a worse one.
64
+ */
65
+ export function shouldStartDrag({
66
+ pointerType,
67
+ button,
68
+ overflowing,
69
+ }: {
70
+ pointerType: string
71
+ button: number
72
+ overflowing: boolean
73
+ }): boolean {
74
+ if (pointerType !== 'mouse') return false
75
+ // Primary button only: the middle button is paste-scroll on some platforms
76
+ // and the secondary opens the context menu.
77
+ if (button !== 0) return false
78
+ return overflowing
79
+ }
80
+
81
+ /**
82
+ * Advance a gesture given the pointer's current x.
83
+ *
84
+ * Returns `scrollLeft: null` while the pointer is still within the threshold,
85
+ * so the caller leaves the viewport alone and lets the click through.
86
+ */
87
+ export function advanceDrag(
88
+ { origin, dragging }: DragState,
89
+ pointerX: number,
90
+ maxScroll: number
91
+ ): DragAdvance {
92
+ const delta = pointerX - origin.pointerX
93
+
94
+ // Once a drag, always a drag: a gesture that wanders back inside the
95
+ // threshold must not hand itself back to the click handler and select
96
+ // whichever tab the pointer happens to be over.
97
+ const isDragging = dragging || Math.abs(delta) >= DRAG_THRESHOLD_PX
98
+ if (isDragging === false) return { dragging: false, scrollLeft: null }
99
+
100
+ // Subtracted, not added: the content follows the pointer, so dragging right
101
+ // reveals what is to the left and the offset falls.
102
+ const next = origin.scrollLeft - delta
103
+
104
+ return { dragging: true, scrollLeft: Math.min(Math.max(next, 0), maxScroll) }
105
+ }
106
+
107
+ /**
108
+ * Whether a click arriving now belongs to a drag that has just ended, and
109
+ * should therefore not reach the tab underneath.
110
+ */
111
+ export function shouldSuppressClick(dragEndedAt: number | null, now: number): boolean {
112
+ if (dragEndedAt == null) return false
113
+ return now - dragEndedAt < CLICK_SUPPRESSION_MS
114
+ }