@byline/admin 6.1.0 → 6.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/forms/form-layout-styles.test.node.d.ts +8 -0
- package/dist/forms/form-layout.d.ts +39 -0
- package/dist/forms/form-layout.js +100 -0
- package/dist/forms/form-page-chrome.d.ts +110 -0
- package/dist/forms/form-page-chrome.js +253 -0
- package/dist/forms/form-renderer.d.ts +2 -18
- package/dist/forms/form-renderer.js +104 -418
- package/dist/forms/form-renderer.module.js +2 -0
- package/dist/forms/form-renderer_module.css +13 -0
- package/dist/forms/use-form-submission.d.ts +74 -0
- package/dist/forms/use-form-submission.js +166 -0
- package/dist/forms/use-form-tabs.d.ts +29 -0
- package/dist/forms/use-form-tabs.js +45 -0
- 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-layout-styles.test.node.ts +32 -0
- package/src/forms/form-layout.test.tsx +276 -0
- package/src/forms/form-layout.tsx +198 -0
- package/src/forms/form-page-chrome.tsx +460 -0
- package/src/forms/form-renderer-submit.test.tsx +40 -0
- package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
- package/src/forms/form-renderer.module.css +18 -0
- package/src/forms/form-renderer.tsx +115 -593
- package/src/forms/use-form-submission.test.tsx +564 -0
- package/src/forms/use-form-submission.ts +293 -0
- package/src/forms/use-form-tabs.ts +89 -0
- 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,74 @@
|
|
|
1
|
+
import type { Field } from '@byline/core';
|
|
2
|
+
import type { DocumentPatch } from '@byline/core/patches';
|
|
3
|
+
/**
|
|
4
|
+
* Payload emitted by the form on Save. Carries the content (field data +
|
|
5
|
+
* patches) alongside the document-grain system fields (path / advertised
|
|
6
|
+
* locales) and per-bucket dirty flags so the host can route each piece to the
|
|
7
|
+
* right write path — versioned for content, immediate/non-versioned for the
|
|
8
|
+
* system fields. See docs/08-internationalization/index.md.
|
|
9
|
+
*/
|
|
10
|
+
export interface SystemFieldsSubmitPayload {
|
|
11
|
+
data: any;
|
|
12
|
+
patches: DocumentPatch[];
|
|
13
|
+
contentDirty: boolean;
|
|
14
|
+
pathDirty: boolean;
|
|
15
|
+
systemPath?: string | null;
|
|
16
|
+
availableLocalesDirty: boolean;
|
|
17
|
+
systemAvailableLocales?: string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Where a save has got to. Mutually exclusive by construction: the form cannot
|
|
21
|
+
* be validating and uploading at once, which two independent booleans allowed.
|
|
22
|
+
*/
|
|
23
|
+
export type SubmissionPhase = {
|
|
24
|
+
kind: 'idle';
|
|
25
|
+
} | {
|
|
26
|
+
kind: 'validating';
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'uploading';
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'confirmingSystemFields';
|
|
31
|
+
payload: SystemFieldsSubmitPayload;
|
|
32
|
+
} | {
|
|
33
|
+
kind: 'submitting';
|
|
34
|
+
};
|
|
35
|
+
export interface UseFormSubmissionOptions {
|
|
36
|
+
mode: 'create' | 'edit';
|
|
37
|
+
fields: Field[];
|
|
38
|
+
/** Persisted document id (edit mode only); feeds server-side upload hooks. */
|
|
39
|
+
documentId?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Only collections that opted into the advertised-locale widget emit the
|
|
42
|
+
* locale set, so the write path never touches
|
|
43
|
+
* `byline_document_available_locales` for the others.
|
|
44
|
+
*/
|
|
45
|
+
advertiseLocales?: boolean;
|
|
46
|
+
onSubmit: (payload: SystemFieldsSubmitPayload) => void | Promise<void>;
|
|
47
|
+
/** Mutations blocked (stale/lock/discarding). Read synchronously, per call. */
|
|
48
|
+
isBlocked: () => boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Called immediately before the form becomes `inert` — once before uploads
|
|
51
|
+
* and once before submission — so the caller can record the focused element
|
|
52
|
+
* and restore it when busy ends. The DOM refs stay in the component; the
|
|
53
|
+
* hook only signals the transition.
|
|
54
|
+
*/
|
|
55
|
+
onBeforeBusy?: () => void;
|
|
56
|
+
}
|
|
57
|
+
export interface UseFormSubmissionResult {
|
|
58
|
+
phase: SubmissionPhase;
|
|
59
|
+
isBusy: boolean;
|
|
60
|
+
submit: () => Promise<void>;
|
|
61
|
+
confirmSystemFields: () => Promise<void>;
|
|
62
|
+
cancelSystemFields: () => void;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Owns one save: validate → upload → (confirm) → submit.
|
|
66
|
+
*
|
|
67
|
+
* Admission is decided by a ref, not by `phase`. Rendered state is not a
|
|
68
|
+
* re-entry guard: two `submit()` calls in the same turn both observe
|
|
69
|
+
* `phase.kind === 'idle'` because React has not rerendered between them. The
|
|
70
|
+
* ref is set synchronously at the top of `submit()` and cleared in `finally`,
|
|
71
|
+
* spanning validation through submission — the whole window, not just the
|
|
72
|
+
* final request. `phase` drives the UI; the ref decides admission.
|
|
73
|
+
*/
|
|
74
|
+
export declare function useFormSubmission(options: UseFormSubmissionOptions): UseFormSubmissionResult;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useRef, useState } from "react";
|
|
3
|
+
import { useTranslation } from "@byline/i18n/react";
|
|
4
|
+
import { useBylineFieldServices } from "../fields/field-services-context.js";
|
|
5
|
+
import { useFormContext } from "./form-context.js";
|
|
6
|
+
import { executeUploadsWithProgress } from "./upload-executor.js";
|
|
7
|
+
const IDLE = {
|
|
8
|
+
kind: 'idle'
|
|
9
|
+
};
|
|
10
|
+
function useFormSubmission(options) {
|
|
11
|
+
const { mode, fields, documentId, advertiseLocales, onSubmit, isBlocked, onBeforeBusy } = options;
|
|
12
|
+
const { getFieldValues, runFieldHooks, validateForm, resetHasChanges, getPatches, getDirtyBreakdown, getSystemPath, getSystemAvailableLocales, setFieldValue, setFieldError, getPendingUploads, clearPendingUploads, setFieldUploading } = useFormContext();
|
|
13
|
+
const { t } = useTranslation('byline-admin');
|
|
14
|
+
const { uploadField } = useBylineFieldServices();
|
|
15
|
+
const [phase, setPhase] = useState(IDLE);
|
|
16
|
+
const inFlightRef = useRef(false);
|
|
17
|
+
const isBusy = 'uploading' === phase.kind || 'submitting' === phase.kind;
|
|
18
|
+
const deliver = useCallback(async (payload)=>{
|
|
19
|
+
if (isBlocked() || 'function' != typeof onSubmit) return void setPhase(IDLE);
|
|
20
|
+
onBeforeBusy?.();
|
|
21
|
+
setPhase({
|
|
22
|
+
kind: 'submitting'
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
await onSubmit(payload);
|
|
26
|
+
resetHasChanges();
|
|
27
|
+
} catch {} finally{
|
|
28
|
+
setPhase(IDLE);
|
|
29
|
+
}
|
|
30
|
+
}, [
|
|
31
|
+
isBlocked,
|
|
32
|
+
onBeforeBusy,
|
|
33
|
+
onSubmit,
|
|
34
|
+
resetHasChanges
|
|
35
|
+
]);
|
|
36
|
+
const runUploads = useCallback(async ()=>{
|
|
37
|
+
const pendingUploads = getPendingUploads();
|
|
38
|
+
if (0 === pendingUploads.size) return true;
|
|
39
|
+
onBeforeBusy?.();
|
|
40
|
+
setPhase({
|
|
41
|
+
kind: 'uploading'
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
const uploadResult = await executeUploadsWithProgress(pendingUploads, uploadField, ({ fieldPath, status })=>{
|
|
45
|
+
setFieldUploading(fieldPath, 'uploading' === status);
|
|
46
|
+
}, {
|
|
47
|
+
documentId: 'edit' === mode ? documentId : void 0,
|
|
48
|
+
fields,
|
|
49
|
+
getFormValues: getFieldValues
|
|
50
|
+
});
|
|
51
|
+
if (!uploadResult.allSucceeded) {
|
|
52
|
+
for (const [fieldPath, errorMessage] of uploadResult.errors.entries())setFieldError(fieldPath, t('forms.uploadFailedFieldError', {
|
|
53
|
+
message: errorMessage
|
|
54
|
+
}));
|
|
55
|
+
console.error('One or more uploads failed:', uploadResult.errors);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
for (const [fieldPath, storedFile] of uploadResult.successful.entries())setFieldValue(fieldPath, storedFile);
|
|
59
|
+
clearPendingUploads();
|
|
60
|
+
return true;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error('Upload execution error:', err);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}, [
|
|
66
|
+
clearPendingUploads,
|
|
67
|
+
documentId,
|
|
68
|
+
fields,
|
|
69
|
+
getFieldValues,
|
|
70
|
+
getPendingUploads,
|
|
71
|
+
mode,
|
|
72
|
+
onBeforeBusy,
|
|
73
|
+
setFieldError,
|
|
74
|
+
setFieldUploading,
|
|
75
|
+
setFieldValue,
|
|
76
|
+
t,
|
|
77
|
+
uploadField
|
|
78
|
+
]);
|
|
79
|
+
const buildPayload = useCallback(()=>{
|
|
80
|
+
const { contentDirty, pathDirty, availableLocalesDirty, reason } = getDirtyBreakdown();
|
|
81
|
+
return {
|
|
82
|
+
reason,
|
|
83
|
+
payload: {
|
|
84
|
+
data: getFieldValues(),
|
|
85
|
+
patches: getPatches(),
|
|
86
|
+
contentDirty,
|
|
87
|
+
pathDirty,
|
|
88
|
+
systemPath: getSystemPath(),
|
|
89
|
+
availableLocalesDirty,
|
|
90
|
+
systemAvailableLocales: advertiseLocales ? getSystemAvailableLocales() : void 0
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}, [
|
|
94
|
+
advertiseLocales,
|
|
95
|
+
getDirtyBreakdown,
|
|
96
|
+
getFieldValues,
|
|
97
|
+
getPatches,
|
|
98
|
+
getSystemAvailableLocales,
|
|
99
|
+
getSystemPath
|
|
100
|
+
]);
|
|
101
|
+
const submit = useCallback(async ()=>{
|
|
102
|
+
if (isBlocked()) return;
|
|
103
|
+
if (inFlightRef.current) return;
|
|
104
|
+
inFlightRef.current = true;
|
|
105
|
+
try {
|
|
106
|
+
setPhase({
|
|
107
|
+
kind: 'validating'
|
|
108
|
+
});
|
|
109
|
+
const hookErrors = await runFieldHooks(fields);
|
|
110
|
+
const formErrors = validateForm(fields);
|
|
111
|
+
const allErrors = [
|
|
112
|
+
...hookErrors,
|
|
113
|
+
...formErrors
|
|
114
|
+
];
|
|
115
|
+
if (allErrors.length > 0) {
|
|
116
|
+
console.error('Form validation failed:', allErrors);
|
|
117
|
+
setPhase(IDLE);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (isBlocked()) return void setPhase(IDLE);
|
|
121
|
+
if (!await runUploads()) return void setPhase(IDLE);
|
|
122
|
+
const { payload, reason } = buildPayload();
|
|
123
|
+
if ('edit' === mode && ('direct-write' === reason || 'both' === reason)) return void setPhase({
|
|
124
|
+
kind: 'confirmingSystemFields',
|
|
125
|
+
payload
|
|
126
|
+
});
|
|
127
|
+
await deliver(payload);
|
|
128
|
+
} finally{
|
|
129
|
+
inFlightRef.current = false;
|
|
130
|
+
}
|
|
131
|
+
}, [
|
|
132
|
+
buildPayload,
|
|
133
|
+
deliver,
|
|
134
|
+
fields,
|
|
135
|
+
isBlocked,
|
|
136
|
+
mode,
|
|
137
|
+
runUploads,
|
|
138
|
+
runFieldHooks,
|
|
139
|
+
validateForm
|
|
140
|
+
]);
|
|
141
|
+
const confirmSystemFields = useCallback(async ()=>{
|
|
142
|
+
if ('confirmingSystemFields' !== phase.kind) return;
|
|
143
|
+
if (inFlightRef.current) return;
|
|
144
|
+
inFlightRef.current = true;
|
|
145
|
+
const { payload } = phase;
|
|
146
|
+
try {
|
|
147
|
+
await deliver(payload);
|
|
148
|
+
} finally{
|
|
149
|
+
inFlightRef.current = false;
|
|
150
|
+
}
|
|
151
|
+
}, [
|
|
152
|
+
deliver,
|
|
153
|
+
phase
|
|
154
|
+
]);
|
|
155
|
+
const cancelSystemFields = useCallback(()=>{
|
|
156
|
+
setPhase((current)=>'confirmingSystemFields' === current.kind ? IDLE : current);
|
|
157
|
+
}, []);
|
|
158
|
+
return {
|
|
159
|
+
phase,
|
|
160
|
+
isBusy,
|
|
161
|
+
submit,
|
|
162
|
+
confirmSystemFields,
|
|
163
|
+
cancelSystemFields
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
export { useFormSubmission };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { TabDefinition, TabSetDefinition } from '@byline/core';
|
|
2
|
+
/** One tab set, resolved against live form data and current errors. */
|
|
3
|
+
export interface ResolvedTabSet {
|
|
4
|
+
/** Tabs whose `condition` is satisfied, in declaration order. */
|
|
5
|
+
visibleTabs: TabDefinition[];
|
|
6
|
+
/** The tab that should render, after falling back off a hidden selection. */
|
|
7
|
+
activeTabName: string;
|
|
8
|
+
activeTab: TabDefinition | undefined;
|
|
9
|
+
/** Error count per tab name, for the tab bar's badges. */
|
|
10
|
+
errorCounts: Record<string, number> | undefined;
|
|
11
|
+
}
|
|
12
|
+
export interface UseFormTabsResult {
|
|
13
|
+
resolve: (set: TabSetDefinition) => ResolvedTabSet;
|
|
14
|
+
errorCountsBySet: Record<string, Record<string, number>>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Tab visibility, per-tab error counts, and fallback when the selected tab is
|
|
18
|
+
* hidden.
|
|
19
|
+
*
|
|
20
|
+
* It deliberately does **not** own the selection. `FormRenderer` lifts that
|
|
21
|
+
* above the keyed `FormProvider` so the editor's tab choices survive the
|
|
22
|
+
* remount a locale change triggers; a hook living inside that subtree would
|
|
23
|
+
* lose them on every switch. The selection arrives as `activeTabBySet` and
|
|
24
|
+
* changes are reported upward.
|
|
25
|
+
*/
|
|
26
|
+
export declare function useFormTabs(fieldToTabPath: Map<string, {
|
|
27
|
+
tabSetName: string;
|
|
28
|
+
tabName: string;
|
|
29
|
+
}>, activeTabBySet: Record<string, string>): UseFormTabsResult;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { useFormContext } from "./form-context.js";
|
|
4
|
+
function useFormTabs(fieldToTabPath, activeTabBySet) {
|
|
5
|
+
const { errors: initialErrors, subscribeErrors, subscribeMeta, getFieldValues } = useFormContext();
|
|
6
|
+
const [errors, setErrors] = useState(initialErrors);
|
|
7
|
+
useEffect(()=>subscribeErrors((newErrors)=>setErrors(newErrors)), [
|
|
8
|
+
subscribeErrors
|
|
9
|
+
]);
|
|
10
|
+
const [formData, setFormData] = useState(()=>getFieldValues());
|
|
11
|
+
useEffect(()=>subscribeMeta(()=>setFormData(getFieldValues())), [
|
|
12
|
+
subscribeMeta,
|
|
13
|
+
getFieldValues
|
|
14
|
+
]);
|
|
15
|
+
const errorCountsBySet = useMemo(()=>{
|
|
16
|
+
const result = {};
|
|
17
|
+
for (const err of errors){
|
|
18
|
+
const path = fieldToTabPath.get(err.field);
|
|
19
|
+
if (path) {
|
|
20
|
+
result[path.tabSetName] ??= {};
|
|
21
|
+
result[path.tabSetName][path.tabName] = (result[path.tabSetName]?.[path.tabName] ?? 0) + 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}, [
|
|
26
|
+
errors,
|
|
27
|
+
fieldToTabPath
|
|
28
|
+
]);
|
|
29
|
+
const resolve = (set)=>{
|
|
30
|
+
const visibleTabs = set.tabs.filter((tab)=>!tab.condition || tab.condition(formData));
|
|
31
|
+
const requested = activeTabBySet[set.name] ?? '';
|
|
32
|
+
const activeTabName = visibleTabs.length > 0 && !visibleTabs.some((t)=>t.name === requested) ? visibleTabs[0]?.name ?? requested : requested;
|
|
33
|
+
return {
|
|
34
|
+
visibleTabs,
|
|
35
|
+
activeTabName,
|
|
36
|
+
activeTab: visibleTabs.find((t)=>t.name === activeTabName),
|
|
37
|
+
errorCounts: errorCountsBySet[set.name]
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
resolve,
|
|
42
|
+
errorCountsBySet
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export { useFormTabs };
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
* Pure state for click-and-drag scrolling of the tab strip.
|
|
10
|
+
*
|
|
11
|
+
* The DOM plumbing — pointer capture, the click suppression, the cursor —
|
|
12
|
+
* lives in `tabs.tsx`; the rules it follows are here so they can be tested
|
|
13
|
+
* without a pointer.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* How far the pointer must travel before the gesture stops being a click and
|
|
17
|
+
* becomes a drag.
|
|
18
|
+
*
|
|
19
|
+
* Without a threshold every click on a tab would nudge the strip by a pixel or
|
|
20
|
+
* two, and the click suppression that follows a drag would swallow the very
|
|
21
|
+
* clicks that select tabs.
|
|
22
|
+
*/
|
|
23
|
+
export declare const DRAG_THRESHOLD_PX = 5;
|
|
24
|
+
/**
|
|
25
|
+
* How long after a drag a click is treated as that drag's own click.
|
|
26
|
+
*
|
|
27
|
+
* A window rather than a consume-once flag, because the click is not
|
|
28
|
+
* guaranteed to arrive: a drag released outside the strip produces none, and
|
|
29
|
+
* a flag left armed would swallow the reader's next deliberate click on a tab.
|
|
30
|
+
* Far longer than the browser's synthetic click takes to arrive, far shorter
|
|
31
|
+
* than anyone can release and deliberately click again.
|
|
32
|
+
*/
|
|
33
|
+
export declare const CLICK_SUPPRESSION_MS = 100;
|
|
34
|
+
/** Where a gesture began. An internal part of `DragState`, not a public type. */
|
|
35
|
+
interface DragOrigin {
|
|
36
|
+
/** Pointer x at the moment the button went down. */
|
|
37
|
+
pointerX: number;
|
|
38
|
+
/** The viewport's scroll offset at that same moment. */
|
|
39
|
+
scrollLeft: number;
|
|
40
|
+
}
|
|
41
|
+
export interface DragState {
|
|
42
|
+
origin: DragOrigin;
|
|
43
|
+
/** Whether the threshold has already been crossed. */
|
|
44
|
+
dragging: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface DragAdvance {
|
|
47
|
+
dragging: boolean;
|
|
48
|
+
/** The offset to scroll to, or `null` while the gesture is still a click. */
|
|
49
|
+
scrollLeft: number | null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether a `pointerdown` should begin a potential drag.
|
|
53
|
+
*
|
|
54
|
+
* Mouse only. Touch and pen already scroll the viewport natively, with
|
|
55
|
+
* momentum and rubber-banding that a hand-rolled drag does not reproduce, so
|
|
56
|
+
* taking the gesture over would trade a good interaction for a worse one.
|
|
57
|
+
*/
|
|
58
|
+
export declare function shouldStartDrag({ pointerType, button, overflowing, }: {
|
|
59
|
+
pointerType: string;
|
|
60
|
+
button: number;
|
|
61
|
+
overflowing: boolean;
|
|
62
|
+
}): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Advance a gesture given the pointer's current x.
|
|
65
|
+
*
|
|
66
|
+
* Returns `scrollLeft: null` while the pointer is still within the threshold,
|
|
67
|
+
* so the caller leaves the viewport alone and lets the click through.
|
|
68
|
+
*/
|
|
69
|
+
export declare function advanceDrag({ origin, dragging }: DragState, pointerX: number, maxScroll: number): DragAdvance;
|
|
70
|
+
/**
|
|
71
|
+
* Whether a click arriving now belongs to a drag that has just ended, and
|
|
72
|
+
* should therefore not reach the tab underneath.
|
|
73
|
+
*/
|
|
74
|
+
export declare function shouldSuppressClick(dragEndedAt: number | null, now: number): boolean;
|
|
75
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const DRAG_THRESHOLD_PX = 5;
|
|
2
|
+
const CLICK_SUPPRESSION_MS = 100;
|
|
3
|
+
function shouldStartDrag({ pointerType, button, overflowing }) {
|
|
4
|
+
if ('mouse' !== pointerType) return false;
|
|
5
|
+
if (0 !== button) return false;
|
|
6
|
+
return overflowing;
|
|
7
|
+
}
|
|
8
|
+
function advanceDrag({ origin, dragging }, pointerX, maxScroll) {
|
|
9
|
+
const delta = pointerX - origin.pointerX;
|
|
10
|
+
const isDragging = dragging || Math.abs(delta) >= DRAG_THRESHOLD_PX;
|
|
11
|
+
if (false === isDragging) return {
|
|
12
|
+
dragging: false,
|
|
13
|
+
scrollLeft: null
|
|
14
|
+
};
|
|
15
|
+
const next = origin.scrollLeft - delta;
|
|
16
|
+
return {
|
|
17
|
+
dragging: true,
|
|
18
|
+
scrollLeft: Math.min(Math.max(next, 0), maxScroll)
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function shouldSuppressClick(dragEndedAt, now) {
|
|
22
|
+
if (null == dragEndedAt) return false;
|
|
23
|
+
return now - dragEndedAt < CLICK_SUPPRESSION_MS;
|
|
24
|
+
}
|
|
25
|
+
export { CLICK_SUPPRESSION_MS, DRAG_THRESHOLD_PX, advanceDrag, shouldStartDrag, shouldSuppressClick };
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
export interface StripMeasurements {
|
|
9
|
+
/**
|
|
10
|
+
* Intrinsic content width of the tablist row.
|
|
11
|
+
*
|
|
12
|
+
* The row is laid out at `max-content`, so this is the width the tabs want
|
|
13
|
+
* regardless of how much room the viewport currently gives them. A row that
|
|
14
|
+
* shrank to its container would report the container's width back and the
|
|
15
|
+
* strip could never detect overflow at all.
|
|
16
|
+
*/
|
|
17
|
+
rowWidth: number;
|
|
18
|
+
/**
|
|
19
|
+
* Usable width of the strip container.
|
|
20
|
+
*
|
|
21
|
+
* Independent of whether the trigger is rendered — the container is sized by
|
|
22
|
+
* the layout above it, not by its own children.
|
|
23
|
+
*/
|
|
24
|
+
containerWidth: number;
|
|
25
|
+
/** Width the overflow trigger occupies when it is rendered. */
|
|
26
|
+
triggerWidth: number;
|
|
27
|
+
/** Gap between the viewport and the trigger. */
|
|
28
|
+
triggerGap: number;
|
|
29
|
+
/** Current horizontal scroll offset of the viewport. */
|
|
30
|
+
scrollLeft: number;
|
|
31
|
+
}
|
|
32
|
+
export interface StripState {
|
|
33
|
+
/** Whether the row needs the scrolling viewport and the overflow trigger. */
|
|
34
|
+
overflowing: boolean;
|
|
35
|
+
/** Width available to the scrolling viewport. */
|
|
36
|
+
viewportWidth: number;
|
|
37
|
+
/** Whether the viewport is scrolled fully left (hides the leading fade). */
|
|
38
|
+
atStart: boolean;
|
|
39
|
+
/** Whether the viewport is scrolled fully right (hides the trailing fade). */
|
|
40
|
+
atEnd: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface RevealMeasurements {
|
|
43
|
+
viewportWidth: number;
|
|
44
|
+
scrollLeft: number;
|
|
45
|
+
/** The tab's offset from the start of the row. */
|
|
46
|
+
tabOffsetLeft: number;
|
|
47
|
+
tabWidth: number;
|
|
48
|
+
rowWidth: number;
|
|
49
|
+
padding?: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve whether the strip overflows, and where it sits within its scroll
|
|
53
|
+
* range.
|
|
54
|
+
*
|
|
55
|
+
* Overflow is decided against the bare container, and deliberately takes no
|
|
56
|
+
* account of the trigger: a row that fits the container fits, and reserving
|
|
57
|
+
* the trigger's width first would summon a menu for tabs that were never cut
|
|
58
|
+
* off. The trigger is subtracted only afterwards, to size the viewport it now
|
|
59
|
+
* shares the container with.
|
|
60
|
+
*
|
|
61
|
+
* That ordering is also what keeps the result stable. The trigger's presence
|
|
62
|
+
* changes the viewport but never `containerWidth` — which the layout above
|
|
63
|
+
* sets — and never `rowWidth`, which the row holds at `max-content`. So the
|
|
64
|
+
* overflow answer cannot feed back into its own inputs.
|
|
65
|
+
*/
|
|
66
|
+
export declare function computeStripState({ rowWidth, containerWidth, triggerWidth, triggerGap, scrollLeft, }: StripMeasurements): StripState;
|
|
67
|
+
/**
|
|
68
|
+
* The scroll offset that brings one tab into view, or `null` when the tab is
|
|
69
|
+
* already visible and the viewport should be left untouched.
|
|
70
|
+
*
|
|
71
|
+
* Returning `null` rather than the current offset is the point: this runs on
|
|
72
|
+
* selection and on focus, and writing `scrollLeft` unconditionally would
|
|
73
|
+
* cancel a scroll the reader is in the middle of.
|
|
74
|
+
*/
|
|
75
|
+
export declare function computeRevealScroll({ viewportWidth, scrollLeft, tabOffsetLeft, tabWidth, rowWidth, padding, }: RevealMeasurements): number | null;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const EDGE_EPSILON = 1;
|
|
2
|
+
const DEFAULT_REVEAL_PADDING = 8;
|
|
3
|
+
function computeStripState({ rowWidth, containerWidth, triggerWidth, triggerGap, scrollLeft }) {
|
|
4
|
+
const measured = containerWidth > 0;
|
|
5
|
+
const overflowing = measured && rowWidth > containerWidth;
|
|
6
|
+
if (false === overflowing) return {
|
|
7
|
+
overflowing: false,
|
|
8
|
+
viewportWidth: Math.max(0, containerWidth),
|
|
9
|
+
atStart: true,
|
|
10
|
+
atEnd: true
|
|
11
|
+
};
|
|
12
|
+
const viewportWidth = Math.max(0, containerWidth - triggerWidth - triggerGap);
|
|
13
|
+
const maxScroll = Math.max(0, rowWidth - viewportWidth);
|
|
14
|
+
return {
|
|
15
|
+
overflowing: true,
|
|
16
|
+
viewportWidth,
|
|
17
|
+
atStart: scrollLeft <= EDGE_EPSILON,
|
|
18
|
+
atEnd: scrollLeft >= maxScroll - EDGE_EPSILON
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function computeRevealScroll({ viewportWidth, scrollLeft, tabOffsetLeft, tabWidth, rowWidth, padding = DEFAULT_REVEAL_PADDING }) {
|
|
22
|
+
const maxScroll = Math.max(0, rowWidth - viewportWidth);
|
|
23
|
+
const clamp = (value)=>Math.min(Math.max(value, 0), maxScroll);
|
|
24
|
+
const leadingEdge = tabOffsetLeft - padding;
|
|
25
|
+
const trailingEdge = tabOffsetLeft + tabWidth + padding;
|
|
26
|
+
let next;
|
|
27
|
+
if (tabWidth + 2 * padding >= viewportWidth) next = leadingEdge;
|
|
28
|
+
else if (leadingEdge < scrollLeft) next = leadingEdge;
|
|
29
|
+
else {
|
|
30
|
+
if (!(trailingEdge > scrollLeft + viewportWidth)) return null;
|
|
31
|
+
next = trailingEdge - viewportWidth;
|
|
32
|
+
}
|
|
33
|
+
const clamped = clamp(next);
|
|
34
|
+
return clamped === scrollLeft ? null : clamped;
|
|
35
|
+
}
|
|
36
|
+
export { computeRevealScroll, computeStripState };
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export {};
|
|
@@ -6,20 +6,43 @@ interface AdminTabsProps {
|
|
|
6
6
|
tabs: AdminTabItem[];
|
|
7
7
|
activeTab: string;
|
|
8
8
|
onChange: (name: string) => void;
|
|
9
|
+
/**
|
|
10
|
+
* Prefix for the generated tab and panel ids. The consumer that renders the
|
|
11
|
+
* panels builds its panel ids from the same base via `tabPanelId`, which is
|
|
12
|
+
* what associates the two halves for assistive technology.
|
|
13
|
+
*/
|
|
14
|
+
idBase: string;
|
|
9
15
|
/** Error counts keyed by tab name — shows a danger badge when > 0. */
|
|
10
16
|
errorCounts?: Record<string, number>;
|
|
11
17
|
className?: string;
|
|
12
18
|
}
|
|
19
|
+
/** Id of the button for one tab. */
|
|
20
|
+
export declare const tabTriggerId: (idBase: string, name: string) => string;
|
|
21
|
+
/** Id the consumer must put on the panel belonging to one tab. */
|
|
22
|
+
export declare const tabPanelId: (idBase: string, name: string) => string;
|
|
13
23
|
/**
|
|
14
24
|
* Tabs navigation bar for admin form layouts.
|
|
15
25
|
*
|
|
16
|
-
* Used by FormRenderer when a CollectionAdminConfig declares a `tabs` array
|
|
17
|
-
*
|
|
18
|
-
*
|
|
26
|
+
* Used by FormRenderer when a CollectionAdminConfig declares a `tabs` array,
|
|
27
|
+
* and by the collection history view. The number of tabs a collection can
|
|
28
|
+
* declare is unbounded while the column they sit in is not, so the strip
|
|
29
|
+
* scrolls horizontally and — only once the tabs genuinely exceed the column —
|
|
30
|
+
* grows a menu listing every tab.
|
|
19
31
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
32
|
+
* The menu is a jump list rather than a list of the tabs that happen to be
|
|
33
|
+
* out of view: its contents stay put as the strip scrolls, and no tab ever
|
|
34
|
+
* changes position in the strip as a result of being selected. Editors learn
|
|
35
|
+
* where a tab lives and it stays there.
|
|
36
|
+
*
|
|
37
|
+
* Scroll position is moved only when the selection changes or a tab takes
|
|
38
|
+
* focus. A reader who scrolls the strip to look at other labels keeps their
|
|
39
|
+
* position through re-measurement, re-render and resize.
|
|
40
|
+
*
|
|
41
|
+
* Stable override handles: `.byline-admin-tabs`, `.byline-admin-tabs-viewport`,
|
|
42
|
+
* `.byline-admin-tablist`, `.byline-admin-tab`, `.byline-admin-tab-active`,
|
|
43
|
+
* `.byline-admin-tab-label`, `.byline-admin-tab-badge`,
|
|
44
|
+
* `.byline-admin-tabs-overflow-trigger`, `.byline-admin-tabs-overflow-menu`,
|
|
45
|
+
* `.byline-admin-tabs-overflow-item`.
|
|
23
46
|
*/
|
|
24
|
-
export declare const AdminTabs: ({ tabs, activeTab, onChange, errorCounts, className, }: AdminTabsProps) => import("react").JSX.Element;
|
|
47
|
+
export declare const AdminTabs: ({ tabs, activeTab, onChange, idBase, errorCounts, className, }: AdminTabsProps) => import("react").JSX.Element;
|
|
25
48
|
export {};
|