@byline/admin 6.0.3 → 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.
@@ -11,8 +11,8 @@
11
11
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
12
12
 
13
13
  import type {
14
+ AdminResourceConfig,
14
15
  Field,
15
- FormAdminConfig,
16
16
  GroupDefinition,
17
17
  RowDefinition,
18
18
  TabSetDefinition,
@@ -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'
@@ -133,7 +133,13 @@ export interface FormRendererProps {
133
133
  workflowStatuses?: WorkflowStatus[]
134
134
  publishedVersion?: PublishedVersionInfo | null
135
135
  initialData?: Record<string, any>
136
- adminConfig?: FormAdminConfig
136
+ /**
137
+ * Presentation configuration for the resource being edited — a collection
138
+ * or a singleton. Typed as the union rather than the shared
139
+ * `FormAdminConfig` base so the renderer can read collection-only members
140
+ * such as `lockPath`.
141
+ */
142
+ adminConfig?: AdminResourceConfig
137
143
  /**
138
144
  * Name of the schema field to render as the live form heading.
139
145
  * Sourced from `CollectionDefinition.useAsTitle` by the caller.
@@ -665,11 +671,13 @@ const FormContent = ({
665
671
  ? (visibleTabs[0]?.name ?? requested)
666
672
  : requested
667
673
  const activeTab = visibleTabs.find((t) => t.name === resolvedActive)
674
+ const idBase = `tabset:${set.name}`
668
675
 
669
676
  return (
670
- <div key={`tabset:${set.name}`} className={cx('byline-form-tabset', styles.tabset)}>
677
+ <div key={idBase} className={cx('byline-form-tabset', styles.tabset)}>
671
678
  {visibleTabs.length > 0 && (
672
679
  <AdminTabs
680
+ idBase={idBase}
673
681
  tabs={visibleTabs}
674
682
  activeTab={resolvedActive}
675
683
  onChange={(tabName) => handleTabChange(set.name, tabName)}
@@ -678,7 +686,12 @@ const FormContent = ({
678
686
  />
679
687
  )}
680
688
  {activeTab && (
681
- <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
+ >
682
695
  {activeTab.fields.map((name) => renderItem(name))}
683
696
  </div>
684
697
  )}
@@ -952,8 +965,13 @@ const FormContent = ({
952
965
  {layout.main.map((name) => renderItem(name))}
953
966
  </div>
954
967
  <div className={cx('byline-form-sidebar', styles.sidebar)}>
968
+ {/* A locked collection's widget renders even with no `useAsPath`
969
+ and nothing stored yet: its path is managed, and the editor
970
+ needs to see that. `showPath: false` still wins — it marks a
971
+ path that must never be presented at all. */}
955
972
  {showPath &&
956
973
  (useAsPath ||
974
+ adminConfig?.lockPath === true ||
957
975
  (typeof initialData?.path === 'string' && initialData.path.length > 0)) && (
958
976
  <PathWidget
959
977
  disabled={mutationsBlocked || discarding}
@@ -964,6 +982,7 @@ const FormContent = ({
964
982
  mode={mode}
965
983
  slugifier={pathSlugifier}
966
984
  sourceLocked={pathSourceLocked}
985
+ lockPath={adminConfig?.lockPath}
967
986
  />
968
987
  )}
969
988
  {tree && mode === 'edit' && typeof initialData?.id === 'string' && (
@@ -110,6 +110,7 @@ describe('PathWidget', () => {
110
110
  activeLocale: string
111
111
  slugifier: (value: string, ctx: { locale: string; collectionPath: string }) => string
112
112
  sourceLocked: boolean
113
+ lockPath: boolean
113
114
  }> = {}
114
115
  ) => {
115
116
  act(() => {
@@ -124,13 +125,14 @@ describe('PathWidget', () => {
124
125
  localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
125
126
  >
126
127
  <PathWidget
127
- useAsPath={props.useAsPath ?? 'title'}
128
+ useAsPath={'useAsPath' in props ? props.useAsPath : 'title'}
128
129
  collectionPath="pages"
129
130
  defaultLocale="en"
130
131
  activeLocale={props.activeLocale ?? 'en'}
131
132
  mode={props.mode ?? 'create'}
132
133
  slugifier={props.slugifier}
133
134
  sourceLocked={props.sourceLocked}
135
+ lockPath={props.lockPath}
134
136
  />
135
137
  </I18nProvider>
136
138
  )
@@ -228,7 +230,7 @@ describe('PathWidget', () => {
228
230
  })
229
231
 
230
232
  it('does not render the Regenerate button when there is no useAsPath', () => {
231
- setFixture({ systemPath: 'whatever', sourceValue: '' })
233
+ setFixture({ systemPath: 'whatever', sourceValue: 'Hello World' })
232
234
  render({ mode: 'edit', useAsPath: undefined })
233
235
  expect(container.querySelector('button')).toBeNull()
234
236
  })
@@ -245,6 +247,27 @@ describe('PathWidget', () => {
245
247
  expect(input.getAttribute('placeholder')).toBe('Will be saved as "0000001"')
246
248
  })
247
249
 
250
+ it('shows the translation-locale hint when editing a translation unlocked', () => {
251
+ setFixture({ systemPath: 'my-path', sourceValue: 'My Path' })
252
+ render({ mode: 'edit', activeLocale: 'fr' })
253
+
254
+ const input = getInput()
255
+ expect(input.readOnly).toBe(true)
256
+ expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe(
257
+ 'Path is set in the default locale ("en") and applies across translations.'
258
+ )
259
+ })
260
+
261
+ it('shows the suggested-slug hint for an un-slugified stored value', () => {
262
+ setFixture({ systemPath: 'Not A Slug', sourceValue: 'My Path' })
263
+ render({ mode: 'edit' })
264
+
265
+ expect(getInput().readOnly).toBe(false)
266
+ expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe(
267
+ 'Suggested: "not-a-slug"'
268
+ )
269
+ })
270
+
248
271
  it('suppresses the create-mode placeholder preview when the source is locked', () => {
249
272
  setFixture({ systemPath: null, sourceValue: 1 })
250
273
  render({ mode: 'create', sourceLocked: true })
@@ -259,4 +282,75 @@ describe('PathWidget', () => {
259
282
  render({ mode: 'edit', sourceLocked: true })
260
283
  expect(container.querySelector('button')).toBeNull()
261
284
  })
285
+
286
+ it('renders read-only and empty when creating in a locked collection', () => {
287
+ setFixture({ systemPath: null, sourceValue: 'Hello World' })
288
+ render({ mode: 'create', lockPath: true })
289
+
290
+ const input = getInput()
291
+ expect(input.readOnly).toBe(true)
292
+ expect(input.value).toBe('')
293
+ expect(input.getAttribute('placeholder')).toBeNull()
294
+ expect(container.querySelector('button')).toBeNull()
295
+ expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe(
296
+ 'Assigned automatically when this document is saved.'
297
+ )
298
+ })
299
+
300
+ it('renders read-only and shows the stored path when editing a locked collection', () => {
301
+ setFixture({ systemPath: 'catalogue-item', sourceValue: 'Hello World' })
302
+ render({ mode: 'edit', lockPath: true })
303
+
304
+ const input = getInput()
305
+ expect(input.readOnly).toBe(true)
306
+ expect(input.value).toBe('catalogue-item')
307
+ // The stored path differs from what the source would derive, so an
308
+ // unlocked widget would offer Regenerate here.
309
+ expect(container.querySelector('button')).toBeNull()
310
+ expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe(
311
+ 'This path is managed and cannot be edited here.'
312
+ )
313
+ })
314
+
315
+ it('prefers the locked hint over the translation-locale hint', () => {
316
+ setFixture({ systemPath: 'catalogue-item', sourceValue: 'Hello World' })
317
+ render({ mode: 'edit', lockPath: true, activeLocale: 'fr' })
318
+
319
+ expect(getInput().readOnly).toBe(true)
320
+ expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe(
321
+ 'This path is managed and cannot be edited here.'
322
+ )
323
+ })
324
+
325
+ it('ignores a change event while locked — the lock is not merely visual', () => {
326
+ setFixture({ systemPath: 'catalogue-item', sourceValue: 'Hello World' })
327
+ render({ mode: 'edit', lockPath: true })
328
+
329
+ // Drive the input through the native prototype setter, exactly as the
330
+ // two typing tests above do. A direct `input.value = …` updates React's
331
+ // value tracker, which then suppresses onChange on its own — the
332
+ // assertion below would pass whether or not the guard exists.
333
+ const input = getInput()
334
+ act(() => {
335
+ const setter = Object.getOwnPropertyDescriptor(
336
+ window.HTMLInputElement.prototype,
337
+ 'value'
338
+ )?.set
339
+ setter?.call(input, 'typed-over')
340
+ input.dispatchEvent(new Event('input', { bubbles: true }))
341
+ })
342
+
343
+ expect(fixture.setSystemPath).not.toHaveBeenCalled()
344
+ })
345
+
346
+ it('leaves a counter-sourced collection editable when lockPath is not set', () => {
347
+ // The explicit guard against reintroducing inference: a locked *source*
348
+ // suppresses the preview and Regenerate, but never the input itself.
349
+ setFixture({ systemPath: '0000448', sourceValue: 448 })
350
+ render({ mode: 'edit', sourceLocked: true })
351
+
352
+ const input = getInput()
353
+ expect(input.readOnly).toBe(false)
354
+ expect(container.querySelector('button')).toBeNull()
355
+ })
262
356
  })
@@ -66,6 +66,14 @@ export interface PathWidgetProps {
66
66
  * persisted path. Defaults to `false`.
67
67
  */
68
68
  sourceLocked?: boolean
69
+ /**
70
+ * When `true`, the collection declares its paths managed
71
+ * (`CollectionAdminConfig.lockPath`): the input renders read-only in both
72
+ * modes and the preview placeholder and "Regenerate" action are
73
+ * suppressed. Independent of `sourceLocked`, which answers whether the
74
+ * form can derive a preview at all. Defaults to `false`.
75
+ */
76
+ lockPath?: boolean
69
77
  }
70
78
 
71
79
  /**
@@ -92,6 +100,7 @@ export const PathWidget = ({
92
100
  mode,
93
101
  slugifier,
94
102
  sourceLocked = false,
103
+ lockPath = false,
95
104
  disabled = false,
96
105
  }: PathWidgetProps) => {
97
106
  const { setSystemPath } = useFormContext()
@@ -105,11 +114,15 @@ export const PathWidget = ({
105
114
  // with what the server persists.
106
115
  const runSlugify = slugifier ?? slugify
107
116
 
108
- // Phase 1: paths are written/edited only under the default content
109
- // locale. When editing a translation, the widget locks down — the
110
- // input is read-only, the Regenerate action is suppressed, and a
111
- // helpText line explains why.
112
- const isReadOnly = activeLocale !== defaultLocale
117
+ // Phase 1: paths are written/edited only under the default content locale,
118
+ // so editing a translation locks the widget down.
119
+ const translationLocked = activeLocale !== defaultLocale
120
+
121
+ // A collection may also declare its paths managed. Both conditions produce
122
+ // the same read-only surface, so the placeholder, the Regenerate
123
+ // affordance, the `readOnly` attribute and the change guard all key off the
124
+ // combined flag.
125
+ const isReadOnly = lockPath || translationLocked
113
126
 
114
127
  // Live preview — what the server would derive from the current source
115
128
  // field value if no override were set. Used as placeholder in create
@@ -127,12 +140,14 @@ export const PathWidget = ({
127
140
 
128
141
  const handleChange = useCallback(
129
142
  (next: string) => {
130
- if (disabled) return
143
+ // `readOnly` stops real typing; a programmatic change event still
144
+ // reaches this handler. Refuse the write so the lock holds in the form.
145
+ if (disabled || isReadOnly) return
131
146
  // Empty string clears the override — server falls back to derive
132
147
  // (create) or sticky (update).
133
148
  setSystemPath(next.length === 0 ? null : next)
134
149
  },
135
- [disabled, setSystemPath]
150
+ [disabled, isReadOnly, setSystemPath]
136
151
  )
137
152
 
138
153
  const handleRegenerate = useCallback(() => {
@@ -154,13 +169,18 @@ export const PathWidget = ({
154
169
  ? t('pathWidget.suggestedHint', { formatted })
155
170
  : undefined
156
171
 
157
- // When read-only, replace the live validation hint with a fixed
158
- // explanatory line so editors understand why the field is locked.
159
- const readOnlyHint = isReadOnly
172
+ // Hint precedence: locked, then translation, then live validation.
173
+ const lockedHint = lockPath
174
+ ? mode === 'create'
175
+ ? t('pathWidget.lockedCreateHint')
176
+ : t('pathWidget.lockedHint')
177
+ : undefined
178
+
179
+ const translationHint = translationLocked
160
180
  ? t('pathWidget.readOnlyHint', { locale: defaultLocale })
161
181
  : undefined
162
182
 
163
- const hint = readOnlyHint ?? validationHint
183
+ const hint = lockedHint ?? translationHint ?? validationHint
164
184
 
165
185
  const placeholder =
166
186
  !isReadOnly && mode === 'create' && livePreview.length > 0
@@ -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
+ }