@byline/admin 6.1.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/forms/form-renderer.js +7 -2
  2. package/dist/presentation/tab-strip-drag.d.ts +75 -0
  3. package/dist/presentation/tab-strip-drag.js +25 -0
  4. package/dist/presentation/tab-strip-drag.test.node.d.ts +8 -0
  5. package/dist/presentation/tab-strip-geometry.d.ts +75 -0
  6. package/dist/presentation/tab-strip-geometry.js +36 -0
  7. package/dist/presentation/tab-strip-geometry.test.node.d.ts +8 -0
  8. package/dist/presentation/tabs.d.ts +30 -7
  9. package/dist/presentation/tabs.js +260 -29
  10. package/dist/presentation/tabs.module.js +11 -1
  11. package/dist/presentation/tabs_module.css +166 -5
  12. package/package.json +7 -7
  13. package/src/forms/form-renderer.tsx +10 -3
  14. package/src/presentation/tab-strip-drag.test.node.ts +143 -0
  15. package/src/presentation/tab-strip-drag.ts +114 -0
  16. package/src/presentation/tab-strip-geometry.test.node.ts +254 -0
  17. package/src/presentation/tab-strip-geometry.ts +158 -0
  18. package/src/presentation/tabs.module.css +209 -11
  19. package/src/presentation/tabs.test.tsx +481 -0
  20. package/src/presentation/tabs.tsx +369 -34
  21. package/dist/exports-parity.test.node.js +0 -37
  22. package/dist/fields/array/array-field.jsx +0 -209
  23. package/dist/fields/blocks/blocks-field.jsx +0 -248
  24. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  25. package/dist/fields/code/code-editor.jsx +0 -211
  26. package/dist/fields/code/code-field.jsx +0 -109
  27. package/dist/fields/column-formatter.jsx +0 -25
  28. package/dist/fields/date-time-formatter.jsx +0 -18
  29. package/dist/fields/datetime/datetime-field.jsx +0 -22
  30. package/dist/fields/draggable-context-menu.jsx +0 -50
  31. package/dist/fields/field-admin.test.node.js +0 -40
  32. package/dist/fields/field-renderer.jsx +0 -136
  33. package/dist/fields/field-services-context.jsx +0 -17
  34. package/dist/fields/file/file-field.jsx +0 -176
  35. package/dist/fields/file/file-upload-field.jsx +0 -89
  36. package/dist/fields/group/group-field.jsx +0 -46
  37. package/dist/fields/image/image-field.jsx +0 -183
  38. package/dist/fields/image/image-upload-field.jsx +0 -157
  39. package/dist/fields/local-date-time.jsx +0 -69
  40. package/dist/fields/locale-badge.jsx +0 -19
  41. package/dist/fields/numerical/numerical-field.jsx +0 -93
  42. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  43. package/dist/fields/relation/relation-display.jsx +0 -118
  44. package/dist/fields/relation/relation-field.jsx +0 -108
  45. package/dist/fields/relation/relation-many-field.jsx +0 -142
  46. package/dist/fields/relation/relation-picker.jsx +0 -240
  47. package/dist/fields/relation/relation-summary.jsx +0 -45
  48. package/dist/fields/select/select-field.jsx +0 -27
  49. package/dist/fields/sortable-item.jsx +0 -54
  50. package/dist/fields/text/text-field.jsx +0 -79
  51. package/dist/fields/text-area/text-area-field.jsx +0 -79
  52. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  53. package/dist/forms/available-locales-widget.jsx +0 -63
  54. package/dist/forms/document-actions.jsx +0 -486
  55. package/dist/forms/form-context.jsx +0 -598
  56. package/dist/forms/form-modals.jsx +0 -128
  57. package/dist/forms/form-renderer.jsx +0 -532
  58. package/dist/forms/form-status-display.jsx +0 -69
  59. package/dist/forms/navigation-guard.jsx +0 -68
  60. package/dist/forms/nested-path.test.node.js +0 -115
  61. package/dist/forms/path-widget.jsx +0 -125
  62. package/dist/forms/pending-uploads.test.node.js +0 -19
  63. package/dist/forms/repeating-items.test.node.js +0 -29
  64. package/dist/forms/scheduled-publication-control.jsx +0 -389
  65. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  66. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  67. package/dist/forms/status-transitions.test.node.js +0 -76
  68. package/dist/forms/tree-placement-widget.jsx +0 -160
  69. package/dist/forms/upload-executor.test.node.js +0 -368
  70. package/dist/forms/use-form-layout.test.node.js +0 -69
  71. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  72. package/dist/modules/admin-account/components/container.jsx +0 -164
  73. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  74. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  75. package/dist/modules/admin-account/components/update.jsx +0 -186
  76. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  77. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  78. package/dist/modules/admin-roles/components/create.jsx +0 -155
  79. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  80. package/dist/modules/admin-roles/components/update.jsx +0 -148
  81. package/dist/modules/admin-users/components/create.jsx +0 -206
  82. package/dist/modules/admin-users/components/roles.jsx +0 -126
  83. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  84. package/dist/modules/admin-users/components/update.jsx +0 -215
  85. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  86. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  87. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  88. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  89. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  90. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  91. package/dist/presentation/group.jsx +0 -26
  92. package/dist/presentation/row.jsx +0 -23
  93. package/dist/presentation/tabs.jsx +0 -39
  94. package/dist/services/admin-services-context.jsx +0 -17
  95. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  96. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  97. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -0,0 +1,254 @@
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 { computeRevealScroll, computeStripState } from './tab-strip-geometry'
12
+
13
+ describe('computeStripState', () => {
14
+ it('reports no overflow when the row fits the container', () => {
15
+ const state = computeStripState({
16
+ rowWidth: 500,
17
+ containerWidth: 600,
18
+ triggerWidth: 40,
19
+ triggerGap: 8,
20
+ scrollLeft: 0,
21
+ })
22
+
23
+ expect(state.overflowing).toBe(false)
24
+ })
25
+
26
+ /**
27
+ * The trigger must never be the reason the trigger appears. A row that fits
28
+ * the bare container fits, full stop — reserving the trigger's width before
29
+ * deciding would summon a menu for tabs that were never cut off.
30
+ */
31
+ it('does not overflow for a row that fits the container but not the reserved width', () => {
32
+ const state = computeStripState({
33
+ rowWidth: 570,
34
+ containerWidth: 600,
35
+ triggerWidth: 40,
36
+ triggerGap: 8,
37
+ scrollLeft: 0,
38
+ })
39
+
40
+ expect(state.overflowing).toBe(false)
41
+ })
42
+
43
+ it('decides overflow independently of the trigger width', () => {
44
+ const measurements = { rowWidth: 570, containerWidth: 600, triggerGap: 8, scrollLeft: 0 }
45
+
46
+ const narrow = computeStripState({ ...measurements, triggerWidth: 0 })
47
+ const wide = computeStripState({ ...measurements, triggerWidth: 200 })
48
+
49
+ expect(narrow.overflowing).toBe(wide.overflowing)
50
+ })
51
+
52
+ it('gives the row the full container width when it is not overflowing', () => {
53
+ const state = computeStripState({
54
+ rowWidth: 500,
55
+ containerWidth: 600,
56
+ triggerWidth: 40,
57
+ triggerGap: 8,
58
+ scrollLeft: 0,
59
+ })
60
+
61
+ expect(state.viewportWidth).toBe(600)
62
+ })
63
+
64
+ it('takes the trigger and its gap out of the viewport once overflowing', () => {
65
+ const state = computeStripState({
66
+ rowWidth: 800,
67
+ containerWidth: 600,
68
+ triggerWidth: 40,
69
+ triggerGap: 8,
70
+ scrollLeft: 0,
71
+ })
72
+
73
+ expect(state).toMatchObject({ overflowing: true, viewportWidth: 552 })
74
+ })
75
+
76
+ it('treats an unmeasured container as not overflowing', () => {
77
+ const state = computeStripState({
78
+ rowWidth: 800,
79
+ containerWidth: 0,
80
+ triggerWidth: 40,
81
+ triggerGap: 8,
82
+ scrollLeft: 0,
83
+ })
84
+
85
+ expect(state.overflowing).toBe(false)
86
+ })
87
+
88
+ it('never reports a negative viewport when the trigger is wider than the container', () => {
89
+ const state = computeStripState({
90
+ rowWidth: 800,
91
+ containerWidth: 30,
92
+ triggerWidth: 40,
93
+ triggerGap: 8,
94
+ scrollLeft: 0,
95
+ })
96
+
97
+ expect(state.viewportWidth).toBe(0)
98
+ })
99
+
100
+ it('reports both edges reached when the row does not overflow', () => {
101
+ const state = computeStripState({
102
+ rowWidth: 500,
103
+ containerWidth: 600,
104
+ triggerWidth: 40,
105
+ triggerGap: 8,
106
+ scrollLeft: 0,
107
+ })
108
+
109
+ expect(state).toMatchObject({ atStart: true, atEnd: true })
110
+ })
111
+
112
+ it('reports the start edge only while scrolled to the left', () => {
113
+ const state = computeStripState({
114
+ rowWidth: 800,
115
+ containerWidth: 600,
116
+ triggerWidth: 40,
117
+ triggerGap: 8,
118
+ scrollLeft: 0,
119
+ })
120
+
121
+ expect(state).toMatchObject({ atStart: true, atEnd: false })
122
+ })
123
+
124
+ it('reports neither edge mid-scroll', () => {
125
+ const state = computeStripState({
126
+ rowWidth: 800,
127
+ containerWidth: 600,
128
+ triggerWidth: 40,
129
+ triggerGap: 8,
130
+ scrollLeft: 100,
131
+ })
132
+
133
+ expect(state).toMatchObject({ atStart: false, atEnd: false })
134
+ })
135
+
136
+ it('reports the end edge when scrolled fully right', () => {
137
+ const state = computeStripState({
138
+ rowWidth: 800,
139
+ containerWidth: 600,
140
+ triggerWidth: 40,
141
+ triggerGap: 8,
142
+ scrollLeft: 248,
143
+ })
144
+
145
+ expect(state).toMatchObject({ atStart: false, atEnd: true })
146
+ })
147
+
148
+ /**
149
+ * Fractional layout widths are routine at non-integer device pixel ratios,
150
+ * so a scroll position a fraction short of the end still counts as the end.
151
+ * Without this the trailing fade never clears.
152
+ */
153
+ it('treats a sub-pixel shortfall as having reached the end', () => {
154
+ const state = computeStripState({
155
+ rowWidth: 800,
156
+ containerWidth: 600,
157
+ triggerWidth: 40,
158
+ triggerGap: 8,
159
+ scrollLeft: 247.6,
160
+ })
161
+
162
+ expect(state.atEnd).toBe(true)
163
+ })
164
+ })
165
+
166
+ describe('computeRevealScroll', () => {
167
+ const base = {
168
+ viewportWidth: 400,
169
+ rowWidth: 1000,
170
+ padding: 8,
171
+ }
172
+
173
+ it('leaves the scroll position alone when the tab is already fully visible', () => {
174
+ const next = computeRevealScroll({
175
+ ...base,
176
+ scrollLeft: 100,
177
+ tabOffsetLeft: 150,
178
+ tabWidth: 80,
179
+ })
180
+
181
+ expect(next).toBeNull()
182
+ })
183
+
184
+ it('scrolls left to bring a tab off the left edge into view', () => {
185
+ const next = computeRevealScroll({
186
+ ...base,
187
+ scrollLeft: 200,
188
+ tabOffsetLeft: 150,
189
+ tabWidth: 80,
190
+ })
191
+
192
+ expect(next).toBe(142)
193
+ })
194
+
195
+ it('scrolls right to bring a tab off the right edge into view', () => {
196
+ const next = computeRevealScroll({
197
+ ...base,
198
+ scrollLeft: 0,
199
+ tabOffsetLeft: 420,
200
+ tabWidth: 80,
201
+ })
202
+
203
+ expect(next).toBe(108)
204
+ })
205
+
206
+ it('clamps to the start rather than scrolling past it', () => {
207
+ const next = computeRevealScroll({
208
+ ...base,
209
+ scrollLeft: 20,
210
+ tabOffsetLeft: 0,
211
+ tabWidth: 80,
212
+ })
213
+
214
+ expect(next).toBe(0)
215
+ })
216
+
217
+ it('clamps to the end rather than scrolling past it', () => {
218
+ const next = computeRevealScroll({
219
+ ...base,
220
+ scrollLeft: 500,
221
+ tabOffsetLeft: 940,
222
+ tabWidth: 60,
223
+ })
224
+
225
+ expect(next).toBe(600)
226
+ })
227
+
228
+ /**
229
+ * A single label can be wider than the whole strip. Aligning its right edge
230
+ * would push the start of the label — the part that identifies it — off the
231
+ * left of the viewport, so an over-wide tab is always aligned to its start.
232
+ */
233
+ it('aligns a tab wider than the viewport to its left edge', () => {
234
+ const next = computeRevealScroll({
235
+ ...base,
236
+ scrollLeft: 0,
237
+ tabOffsetLeft: 500,
238
+ tabWidth: 450,
239
+ })
240
+
241
+ expect(next).toBe(492)
242
+ })
243
+
244
+ it('does not fight a reveal that would not move the scroll position', () => {
245
+ const next = computeRevealScroll({
246
+ ...base,
247
+ scrollLeft: 0,
248
+ tabOffsetLeft: 0,
249
+ tabWidth: 450,
250
+ })
251
+
252
+ expect(next).toBeNull()
253
+ })
254
+ })
@@ -0,0 +1,158 @@
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 geometry for the admin tab strip's overflow behaviour.
11
+ *
12
+ * The DOM reads and the React state live in `tabs.tsx`; every decision they
13
+ * drive is made here, so the rules are testable without a layout engine.
14
+ */
15
+
16
+ /**
17
+ * Layout widths tolerate sub-pixel error at fractional device pixel ratios. A
18
+ * scroll position within this many pixels of an edge counts as having reached
19
+ * it — without the tolerance a trailing fade would never clear.
20
+ */
21
+ const EDGE_EPSILON = 1
22
+
23
+ /** Breathing room left beside a tab when scrolling it into view. */
24
+ const DEFAULT_REVEAL_PADDING = 8
25
+
26
+ export interface StripMeasurements {
27
+ /**
28
+ * Intrinsic content width of the tablist row.
29
+ *
30
+ * The row is laid out at `max-content`, so this is the width the tabs want
31
+ * regardless of how much room the viewport currently gives them. A row that
32
+ * shrank to its container would report the container's width back and the
33
+ * strip could never detect overflow at all.
34
+ */
35
+ rowWidth: number
36
+ /**
37
+ * Usable width of the strip container.
38
+ *
39
+ * Independent of whether the trigger is rendered — the container is sized by
40
+ * the layout above it, not by its own children.
41
+ */
42
+ containerWidth: number
43
+ /** Width the overflow trigger occupies when it is rendered. */
44
+ triggerWidth: number
45
+ /** Gap between the viewport and the trigger. */
46
+ triggerGap: number
47
+ /** Current horizontal scroll offset of the viewport. */
48
+ scrollLeft: number
49
+ }
50
+
51
+ export interface StripState {
52
+ /** Whether the row needs the scrolling viewport and the overflow trigger. */
53
+ overflowing: boolean
54
+ /** Width available to the scrolling viewport. */
55
+ viewportWidth: number
56
+ /** Whether the viewport is scrolled fully left (hides the leading fade). */
57
+ atStart: boolean
58
+ /** Whether the viewport is scrolled fully right (hides the trailing fade). */
59
+ atEnd: boolean
60
+ }
61
+
62
+ export interface RevealMeasurements {
63
+ viewportWidth: number
64
+ scrollLeft: number
65
+ /** The tab's offset from the start of the row. */
66
+ tabOffsetLeft: number
67
+ tabWidth: number
68
+ rowWidth: number
69
+ padding?: number
70
+ }
71
+
72
+ /**
73
+ * Resolve whether the strip overflows, and where it sits within its scroll
74
+ * range.
75
+ *
76
+ * Overflow is decided against the bare container, and deliberately takes no
77
+ * account of the trigger: a row that fits the container fits, and reserving
78
+ * the trigger's width first would summon a menu for tabs that were never cut
79
+ * off. The trigger is subtracted only afterwards, to size the viewport it now
80
+ * shares the container with.
81
+ *
82
+ * That ordering is also what keeps the result stable. The trigger's presence
83
+ * changes the viewport but never `containerWidth` — which the layout above
84
+ * sets — and never `rowWidth`, which the row holds at `max-content`. So the
85
+ * overflow answer cannot feed back into its own inputs.
86
+ */
87
+ export function computeStripState({
88
+ rowWidth,
89
+ containerWidth,
90
+ triggerWidth,
91
+ triggerGap,
92
+ scrollLeft,
93
+ }: StripMeasurements): StripState {
94
+ // A container with no measured width has not been laid out yet. Reporting
95
+ // overflow here would flash a trigger onto the first paint.
96
+ const measured = containerWidth > 0
97
+ const overflowing = measured && rowWidth > containerWidth
98
+
99
+ if (overflowing === false) {
100
+ return {
101
+ overflowing: false,
102
+ viewportWidth: Math.max(0, containerWidth),
103
+ atStart: true,
104
+ atEnd: true,
105
+ }
106
+ }
107
+
108
+ const viewportWidth = Math.max(0, containerWidth - triggerWidth - triggerGap)
109
+ const maxScroll = Math.max(0, rowWidth - viewportWidth)
110
+
111
+ return {
112
+ overflowing: true,
113
+ viewportWidth,
114
+ atStart: scrollLeft <= EDGE_EPSILON,
115
+ atEnd: scrollLeft >= maxScroll - EDGE_EPSILON,
116
+ }
117
+ }
118
+
119
+ /**
120
+ * The scroll offset that brings one tab into view, or `null` when the tab is
121
+ * already visible and the viewport should be left untouched.
122
+ *
123
+ * Returning `null` rather than the current offset is the point: this runs on
124
+ * selection and on focus, and writing `scrollLeft` unconditionally would
125
+ * cancel a scroll the reader is in the middle of.
126
+ */
127
+ export function computeRevealScroll({
128
+ viewportWidth,
129
+ scrollLeft,
130
+ tabOffsetLeft,
131
+ tabWidth,
132
+ rowWidth,
133
+ padding = DEFAULT_REVEAL_PADDING,
134
+ }: RevealMeasurements): number | null {
135
+ const maxScroll = Math.max(0, rowWidth - viewportWidth)
136
+ const clamp = (value: number) => Math.min(Math.max(value, 0), maxScroll)
137
+
138
+ const leadingEdge = tabOffsetLeft - padding
139
+ const trailingEdge = tabOffsetLeft + tabWidth + padding
140
+
141
+ let next: number
142
+
143
+ if (tabWidth + padding * 2 >= viewportWidth) {
144
+ // The tab is wider than the viewport, so it cannot be framed. Align its
145
+ // start: aligning the end would push the beginning of the label — the part
146
+ // that identifies it — off the left of the viewport.
147
+ next = leadingEdge
148
+ } else if (leadingEdge < scrollLeft) {
149
+ next = leadingEdge
150
+ } else if (trailingEdge > scrollLeft + viewportWidth) {
151
+ next = trailingEdge - viewportWidth
152
+ } else {
153
+ return null
154
+ }
155
+
156
+ const clamped = clamp(next)
157
+ return clamped === scrollLeft ? null : clamped
158
+ }
@@ -1,31 +1,149 @@
1
1
  /**
2
2
  * Tabs — navigation bar for admin form layouts.
3
3
  *
4
+ * The strip is a scrolling viewport plus an overflow trigger that appears only
5
+ * when the tabs exceed the column. `--byline-admin-tabs-trigger-size` and
6
+ * `--byline-admin-tabs-trigger-gap` are set on the element by tabs.tsx, so the
7
+ * trigger is laid out from the same numbers the overflow geometry reserves
8
+ * space with.
9
+ *
4
10
  * Override handles:
5
- * .byline-admin-tabs — the tablist container
6
- * .byline-admin-tab — each tab button
7
- * .byline-admin-tab-active — added when the tab is the active one
8
- * .byline-admin-tab-label — the inner label span (with optional badge)
9
- * .byline-admin-tab-badge — the inline error-count badge
11
+ * .byline-admin-tabs — the strip container
12
+ * .byline-admin-tabs-viewport — the horizontally scrolling viewport
13
+ * .byline-admin-tablist — the tablist row inside the viewport
14
+ * .byline-admin-tab — each tab button
15
+ * .byline-admin-tab-active — added when the tab is the active one
16
+ * .byline-admin-tab-label — the inner label span (with optional badge)
17
+ * .byline-admin-tab-badge — the inline error-count badge
18
+ * .byline-admin-tabs-overflow-trigger — the "all tabs" menu button
19
+ * .byline-admin-tabs-overflow-icon — the icon inside that button
20
+ * .byline-admin-tabs-overflow-menu — the menu popup
21
+ * .byline-admin-tabs-overflow-item — each menu item
10
22
  */
11
23
 
12
24
  .tabs,
13
25
  :global(.byline-admin-tabs) {
26
+ position: relative;
14
27
  display: flex;
28
+ align-items: stretch;
29
+ gap: var(--byline-admin-tabs-trigger-gap, 12px);
30
+ }
31
+
32
+ /* The strip's hairline, drawn as a pseudo-element rather than a border so it
33
+ spans the viewport and the trigger together and so the active tab's 2px
34
+ underline can paint over it. */
35
+ .tabs::after,
36
+ :global(.byline-admin-tabs)::after {
37
+ content: "";
38
+ position: absolute;
39
+ right: 0;
40
+ bottom: 0;
41
+ left: 0;
42
+ z-index: 0;
43
+ height: var(--border-width-thin);
44
+ background-color: var(--gray-200);
45
+ }
46
+
47
+ .viewport,
48
+ :global(.byline-admin-tabs-viewport) {
49
+ position: relative;
50
+ z-index: 1;
51
+ flex: 1 1 auto;
52
+ /* A flex item refuses to shrink below its content's min-content width
53
+ unless told otherwise, which would push the row past the column instead
54
+ of scrolling it. */
55
+ min-width: 0;
56
+ overflow-x: auto;
57
+ overflow-y: hidden;
58
+ /* Stops a horizontal flick or trackpad swipe at either end of the strip
59
+ from chaining into the browser's back/forward navigation. */
60
+ overscroll-behavior-x: contain;
61
+ scrollbar-width: none;
62
+ }
63
+
64
+ .viewport::-webkit-scrollbar,
65
+ :global(.byline-admin-tabs-viewport)::-webkit-scrollbar {
66
+ display: none;
67
+ }
68
+
69
+ /* Drag-to-scroll affordance. Only an overflowing strip has anywhere to go, so
70
+ only an overflowing strip offers the grab cursor. The tabs keep their own
71
+ pointer cursor, so they still read as clickable. */
72
+ .viewport[data-draggable="true"],
73
+ :global(.byline-admin-tabs-viewport)[data-draggable="true"] {
74
+ cursor: grab;
75
+ }
76
+
77
+ .viewport[data-dragging="true"],
78
+ :global(.byline-admin-tabs-viewport)[data-dragging="true"] {
79
+ cursor: grabbing;
80
+ /* Suppress text selection for the duration: without this a drag across the
81
+ labels selects them, and the selection highlight follows the pointer. */
82
+ user-select: none;
83
+ -webkit-user-select: none;
84
+ }
85
+
86
+ .viewport[data-dragging="true"] :global(.byline-admin-tab),
87
+ :global(.byline-admin-tabs-viewport)[data-dragging="true"] :global(.byline-admin-tab) {
88
+ cursor: grabbing;
89
+ }
90
+
91
+ /* Edge fades, applied as a mask so they neither scroll with the content nor
92
+ need elements of their own. Each side is sized to zero unless that edge has
93
+ something beyond it, which tabs.tsx reports via the data attributes. */
94
+ .viewport,
95
+ :global(.byline-admin-tabs-viewport) {
96
+ --fade-start: 0px;
97
+ --fade-end: 0px;
98
+ -webkit-mask-image: linear-gradient(
99
+ to right,
100
+ transparent 0,
101
+ black var(--fade-start),
102
+ black calc(100% - var(--fade-end)),
103
+ transparent 100%
104
+ );
105
+ mask-image: linear-gradient(
106
+ to right,
107
+ transparent 0,
108
+ black var(--fade-start),
109
+ black calc(100% - var(--fade-end)),
110
+ transparent 100%
111
+ );
112
+ }
113
+
114
+ .viewport[data-at-start="false"],
115
+ :global(.byline-admin-tabs-viewport)[data-at-start="false"] {
116
+ --fade-start: 1.5rem;
117
+ }
118
+
119
+ .viewport[data-at-end="false"],
120
+ :global(.byline-admin-tabs-viewport)[data-at-end="false"] {
121
+ --fade-end: 1.5rem;
122
+ }
123
+
124
+ .tablist,
125
+ :global(.byline-admin-tablist) {
126
+ /* `max-content` keeps the row at its intrinsic width whatever the viewport
127
+ is doing. A row that shrank to fit would report the viewport's width back
128
+ to the overflow geometry, which could then never detect overflow. It also
129
+ makes the row the offset parent the reveal arithmetic measures against. */
130
+ position: relative;
131
+ display: flex;
132
+ width: max-content;
15
133
  gap: var(--spacing-16);
16
- border-bottom: var(--border-width-thin) var(--border-style-solid) var(--gray-200);
17
134
  }
18
135
 
19
136
  .tab,
20
137
  :global(.byline-admin-tab) {
21
138
  position: relative;
22
- margin-bottom: -1px;
139
+ flex: none;
23
140
  padding: 0.625rem 0;
24
141
  border-bottom: 2px solid transparent;
25
142
  background: none;
26
143
  color: var(--gray-500);
27
144
  font-size: 1.1rem;
28
145
  font-weight: var(--font-weight-medium);
146
+ white-space: nowrap;
29
147
  cursor: pointer;
30
148
  transition:
31
149
  color 150ms ease,
@@ -39,9 +157,12 @@
39
157
  border-bottom-color: var(--gray-300);
40
158
  }
41
159
 
160
+ /* An inset ring rather than an outset one: the viewport clips overflow, and an
161
+ outset focus ring on the first or last tab would be cut in half. */
42
162
  .tab:focus-visible,
43
163
  :global(.byline-admin-tab):focus-visible {
44
- box-shadow: 0 0 0 2px var(--blue-500);
164
+ box-shadow: inset 0 0 0 2px var(--blue-500);
165
+ border-radius: var(--radius-sm, 3px);
45
166
  }
46
167
 
47
168
  .tab-active,
@@ -74,12 +195,73 @@
74
195
  font-size: 0.7rem;
75
196
  }
76
197
 
198
+ .overflow-trigger,
199
+ :global(.byline-admin-tabs-overflow-trigger) {
200
+ position: relative;
201
+ z-index: 1;
202
+ flex: none;
203
+ align-self: center;
204
+ display: flex;
205
+ align-items: center;
206
+ justify-content: center;
207
+ width: var(--byline-admin-tabs-trigger-size, 32px);
208
+ height: var(--byline-admin-tabs-trigger-size, 32px);
209
+ padding: 0;
210
+ border: var(--border-width-thin) var(--border-style-solid) transparent;
211
+ border-radius: var(--radius-sm, 3px);
212
+ background: none;
213
+ color: var(--gray-500);
214
+ cursor: pointer;
215
+ transition:
216
+ color 150ms ease,
217
+ background-color 150ms ease;
218
+ }
219
+
220
+ .overflow-trigger:hover,
221
+ :global(.byline-admin-tabs-overflow-trigger):hover {
222
+ color: var(--gray-800);
223
+ background-color: var(--gray-100);
224
+ }
225
+
226
+ .overflow-trigger:focus-visible,
227
+ :global(.byline-admin-tabs-overflow-trigger):focus-visible {
228
+ outline: 2px solid var(--blue-500);
229
+ outline-offset: 1px;
230
+ }
231
+
232
+ .overflow-icon,
233
+ :global(.byline-admin-tabs-overflow-icon) {
234
+ width: 1.125rem;
235
+ height: 1.125rem;
236
+ }
237
+
238
+ .overflow-menu,
239
+ :global(.byline-admin-tabs-overflow-menu) {
240
+ min-width: 12rem;
241
+ max-height: 60vh;
242
+ overflow-y: auto;
243
+ }
244
+
245
+ .overflow-item,
246
+ :global(.byline-admin-tabs-overflow-item) {
247
+ display: flex;
248
+ align-items: center;
249
+ justify-content: space-between;
250
+ gap: var(--spacing-8);
251
+ }
252
+
253
+ .overflow-item[data-active="true"],
254
+ :global(.byline-admin-tabs-overflow-item)[data-active="true"] {
255
+ color: var(--primary-600);
256
+ font-weight: var(--font-weight-medium);
257
+ }
258
+
77
259
  /* ─── Dark theme variants ───────────────────────────────────── */
78
260
 
79
261
  :is([data-theme="dark"], :global(.dark)) {
80
- .tabs,
81
- :global(.byline-admin-tabs) {
82
- border-bottom-color: var(--gray-700);
262
+ .tabs::after,
263
+ :global(.byline-admin-tabs)::after {
264
+ background-color: var(--gray-700);
83
265
  }
84
266
 
85
267
  .tab,
@@ -104,4 +286,20 @@
104
286
  color: var(--primary-200);
105
287
  border-bottom-color: var(--primary-400);
106
288
  }
289
+
290
+ .overflow-trigger,
291
+ :global(.byline-admin-tabs-overflow-trigger) {
292
+ color: var(--gray-400);
293
+ }
294
+
295
+ .overflow-trigger:hover,
296
+ :global(.byline-admin-tabs-overflow-trigger):hover {
297
+ color: var(--gray-200);
298
+ background-color: var(--gray-800);
299
+ }
300
+
301
+ .overflow-item[data-active="true"],
302
+ :global(.byline-admin-tabs-overflow-item)[data-active="true"] {
303
+ color: var(--primary-200);
304
+ }
107
305
  }