@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
@@ -1,18 +1,109 @@
1
1
  :is(.tabs-AUVgW2, .byline-admin-tabs) {
2
+ align-items: stretch;
3
+ gap: var(--byline-admin-tabs-trigger-gap, 12px);
4
+ display: flex;
5
+ position: relative;
6
+ }
7
+
8
+ .tabs-AUVgW2:after {
9
+ content: "";
10
+ z-index: 0;
11
+ height: var(--border-width-thin);
12
+ background-color: var(--gray-200);
13
+ position: absolute;
14
+ bottom: 0;
15
+ left: 0;
16
+ right: 0;
17
+ }
18
+
19
+ .byline-admin-tabs:after {
20
+ content: "";
21
+ z-index: 0;
22
+ height: var(--border-width-thin);
23
+ background-color: var(--gray-200);
24
+ position: absolute;
25
+ bottom: 0;
26
+ left: 0;
27
+ right: 0;
28
+ }
29
+
30
+ :is(.viewport-Q34w7N, .byline-admin-tabs-viewport) {
31
+ z-index: 1;
32
+ overscroll-behavior-x: contain;
33
+ scrollbar-width: none;
34
+ flex: auto;
35
+ min-width: 0;
36
+ position: relative;
37
+ overflow: auto hidden;
38
+ }
39
+
40
+ .viewport-Q34w7N::-webkit-scrollbar {
41
+ display: none;
42
+ }
43
+
44
+ .byline-admin-tabs-viewport::-webkit-scrollbar {
45
+ display: none;
46
+ }
47
+
48
+ :is(.viewport-Q34w7N[data-draggable="true"], .byline-admin-tabs-viewport[data-draggable="true"]) {
49
+ cursor: grab;
50
+ }
51
+
52
+ :is(.viewport-Q34w7N[data-dragging="true"], .byline-admin-tabs-viewport[data-dragging="true"]) {
53
+ cursor: grabbing;
54
+ -webkit-user-select: none;
55
+ user-select: none;
56
+ }
57
+
58
+ :is(.viewport-Q34w7N[data-dragging="true"] .byline-admin-tab, .byline-admin-tabs-viewport[data-dragging="true"] .byline-admin-tab) {
59
+ cursor: grabbing;
60
+ }
61
+
62
+ :is(.viewport-Q34w7N, .byline-admin-tabs-viewport) {
63
+ --fade-start: 0px;
64
+ --fade-end: 0px;
65
+ -webkit-mask-image: linear-gradient(to right,
66
+ transparent 0,
67
+ black var(--fade-start),
68
+ black calc(100% - var(--fade-end)),
69
+ transparent 100%);
70
+ -webkit-mask-image: linear-gradient(to right,
71
+ transparent 0,
72
+ black var(--fade-start),
73
+ black calc(100% - var(--fade-end)),
74
+ transparent 100%);
75
+ mask-image: linear-gradient(to right,
76
+ transparent 0,
77
+ black var(--fade-start),
78
+ black calc(100% - var(--fade-end)),
79
+ transparent 100%);
80
+ }
81
+
82
+ :is(.viewport-Q34w7N[data-at-start="false"], .byline-admin-tabs-viewport[data-at-start="false"]) {
83
+ --fade-start: 1.5rem;
84
+ }
85
+
86
+ :is(.viewport-Q34w7N[data-at-end="false"], .byline-admin-tabs-viewport[data-at-end="false"]) {
87
+ --fade-end: 1.5rem;
88
+ }
89
+
90
+ :is(.tablist-a3JXdm, .byline-admin-tablist) {
2
91
  gap: var(--spacing-16);
3
- border-bottom: var(--border-width-thin) var(--border-style-solid) var(--gray-200);
92
+ width: max-content;
4
93
  display: flex;
94
+ position: relative;
5
95
  }
6
96
 
7
97
  :is(.tab-Tn7xcQ, .byline-admin-tab) {
8
98
  color: var(--gray-500);
9
99
  font-size: 1.1rem;
10
100
  font-weight: var(--font-weight-medium);
101
+ white-space: nowrap;
11
102
  cursor: pointer;
12
103
  background: none;
13
104
  border-bottom: 2px solid #0000;
14
105
  outline: none;
15
- margin-bottom: -1px;
106
+ flex: none;
16
107
  padding: .625rem 0;
17
108
  transition: color .15s, border-color .15s;
18
109
  position: relative;
@@ -24,7 +115,8 @@
24
115
  }
25
116
 
26
117
  :is(.tab-Tn7xcQ:focus-visible, .byline-admin-tab:focus-visible) {
27
- box-shadow: 0 0 0 2px var(--blue-500);
118
+ box-shadow: inset 0 0 0 2px var(--blue-500);
119
+ border-radius: var(--radius-sm, 3px);
28
120
  }
29
121
 
30
122
  :is(.tab-active-cGf5N5, .byline-admin-tab-active), :is(.tab-active-cGf5N5:hover, .byline-admin-tab-active:hover) {
@@ -48,8 +140,64 @@
48
140
  display: flex;
49
141
  }
50
142
 
51
- :is(:is([data-theme="dark"], .dark) .tabs-AUVgW2, :is([data-theme="dark"], .dark) .byline-admin-tabs) {
52
- border-bottom-color: var(--gray-700);
143
+ :is(.overflow-trigger-sQPQE8, .byline-admin-tabs-overflow-trigger) {
144
+ z-index: 1;
145
+ width: var(--byline-admin-tabs-trigger-size, 32px);
146
+ height: var(--byline-admin-tabs-trigger-size, 32px);
147
+ border: var(--border-width-thin) var(--border-style-solid) transparent;
148
+ border-radius: var(--radius-sm, 3px);
149
+ color: var(--gray-500);
150
+ cursor: pointer;
151
+ background: none;
152
+ flex: none;
153
+ justify-content: center;
154
+ align-self: center;
155
+ align-items: center;
156
+ padding: 0;
157
+ transition: color .15s, background-color .15s;
158
+ display: flex;
159
+ position: relative;
160
+ }
161
+
162
+ :is(.overflow-trigger-sQPQE8:hover, .byline-admin-tabs-overflow-trigger:hover) {
163
+ color: var(--gray-800);
164
+ background-color: var(--gray-100);
165
+ }
166
+
167
+ :is(.overflow-trigger-sQPQE8:focus-visible, .byline-admin-tabs-overflow-trigger:focus-visible) {
168
+ outline: 2px solid var(--blue-500);
169
+ outline-offset: 1px;
170
+ }
171
+
172
+ :is(.overflow-icon-E4aR_Z, .byline-admin-tabs-overflow-icon) {
173
+ width: 1.125rem;
174
+ height: 1.125rem;
175
+ }
176
+
177
+ :is(.overflow-menu-BkkmwS, .byline-admin-tabs-overflow-menu) {
178
+ min-width: 12rem;
179
+ max-height: 60vh;
180
+ overflow-y: auto;
181
+ }
182
+
183
+ :is(.overflow-item-ahgTA4, .byline-admin-tabs-overflow-item) {
184
+ justify-content: space-between;
185
+ align-items: center;
186
+ gap: var(--spacing-8);
187
+ display: flex;
188
+ }
189
+
190
+ :is(.overflow-item-ahgTA4[data-active="true"], .byline-admin-tabs-overflow-item[data-active="true"]) {
191
+ color: var(--primary-600);
192
+ font-weight: var(--font-weight-medium);
193
+ }
194
+
195
+ :is([data-theme="dark"], .dark) .tabs-AUVgW2:after {
196
+ background-color: var(--gray-700);
197
+ }
198
+
199
+ :is([data-theme="dark"], .dark) .byline-admin-tabs:after {
200
+ background-color: var(--gray-700);
53
201
  }
54
202
 
55
203
  :is(:is([data-theme="dark"], .dark) .tab-Tn7xcQ, :is([data-theme="dark"], .dark) .byline-admin-tab) {
@@ -66,3 +214,16 @@
66
214
  border-bottom-color: var(--primary-400);
67
215
  }
68
216
 
217
+ :is(:is([data-theme="dark"], .dark) .overflow-trigger-sQPQE8, :is([data-theme="dark"], .dark) .byline-admin-tabs-overflow-trigger) {
218
+ color: var(--gray-400);
219
+ }
220
+
221
+ :is(:is([data-theme="dark"], .dark) .overflow-trigger-sQPQE8:hover, :is([data-theme="dark"], .dark) .byline-admin-tabs-overflow-trigger:hover) {
222
+ color: var(--gray-200);
223
+ background-color: var(--gray-800);
224
+ }
225
+
226
+ :is(:is([data-theme="dark"], .dark) .overflow-item-ahgTA4[data-active="true"], :is([data-theme="dark"], .dark) .byline-admin-tabs-overflow-item[data-active="true"]) {
227
+ color: var(--primary-200);
228
+ }
229
+
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/admin",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "6.1.0",
5
+ "version": "6.2.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -183,12 +183,12 @@
183
183
  "react-diff-viewer-continued": "^4.4.0",
184
184
  "uuid": "^14.0.2",
185
185
  "zod": "^4.6.2",
186
- "@byline/analytics": "6.1.0",
187
- "@byline/analytics-agent": "6.1.0",
188
- "@byline/core": "6.1.0",
189
- "@byline/auth": "6.1.0",
190
- "@byline/i18n": "6.1.0",
191
- "@byline/ui": "6.1.0"
186
+ "@byline/analytics": "6.2.0",
187
+ "@byline/analytics-agent": "6.2.0",
188
+ "@byline/core": "6.2.0",
189
+ "@byline/auth": "6.2.0",
190
+ "@byline/i18n": "6.2.0",
191
+ "@byline/ui": "6.2.0"
192
192
  },
193
193
  "peerDependencies": {
194
194
  "react": "^19.0.0",
@@ -29,7 +29,7 @@ import { FieldRenderer } from '../fields/field-renderer'
29
29
  import { useBylineFieldServices } from '../fields/field-services-context'
30
30
  import { AdminGroup } from '../presentation/group'
31
31
  import { AdminRow } from '../presentation/row'
32
- import { AdminTabs } from '../presentation/tabs'
32
+ import { AdminTabs, tabPanelId, tabTriggerId } from '../presentation/tabs'
33
33
  import { AvailableLocalesWidget } from './available-locales-widget'
34
34
  import { DocumentActions, type DocumentActionsLocaleOption } from './document-actions'
35
35
  import { FormProvider, useFieldValue, useFormContext } from './form-context'
@@ -671,11 +671,13 @@ const FormContent = ({
671
671
  ? (visibleTabs[0]?.name ?? requested)
672
672
  : requested
673
673
  const activeTab = visibleTabs.find((t) => t.name === resolvedActive)
674
+ const idBase = `tabset:${set.name}`
674
675
 
675
676
  return (
676
- <div key={`tabset:${set.name}`} className={cx('byline-form-tabset', styles.tabset)}>
677
+ <div key={idBase} className={cx('byline-form-tabset', styles.tabset)}>
677
678
  {visibleTabs.length > 0 && (
678
679
  <AdminTabs
680
+ idBase={idBase}
679
681
  tabs={visibleTabs}
680
682
  activeTab={resolvedActive}
681
683
  onChange={(tabName) => handleTabChange(set.name, tabName)}
@@ -684,7 +686,12 @@ const FormContent = ({
684
686
  />
685
687
  )}
686
688
  {activeTab && (
687
- <div className={cx('byline-form-tabset-fields', styles['tabset-fields'])}>
689
+ <div
690
+ role="tabpanel"
691
+ id={tabPanelId(idBase, activeTab.name)}
692
+ aria-labelledby={tabTriggerId(idBase, activeTab.name)}
693
+ className={cx('byline-form-tabset-fields', styles['tabset-fields'])}
694
+ >
688
695
  {activeTab.fields.map((name) => renderItem(name))}
689
696
  </div>
690
697
  )}
@@ -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
+ }