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