@visns-studio/visns-components 6.2.1 → 6.3.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/package.json
CHANGED
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"react-dom": "^17.0.0 || ^18.0.0"
|
|
92
92
|
},
|
|
93
93
|
"name": "@visns-studio/visns-components",
|
|
94
|
-
"version": "6.
|
|
94
|
+
"version": "6.3.1",
|
|
95
95
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
96
96
|
"main": "src/index.js",
|
|
97
97
|
"files": [
|
|
@@ -168,6 +168,33 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
168
168
|
* tab itself has already replaced.
|
|
169
169
|
*/
|
|
170
170
|
const expectedDetailHashRef = useRef(null);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The `{label, detail}` the server is believed to hold, as a comparable
|
|
174
|
+
* string.
|
|
175
|
+
*
|
|
176
|
+
* Every authoring action on this screen — adding a question, rewording an
|
|
177
|
+
* option, changing a size — only moves React state. Nothing reaches the
|
|
178
|
+
* server until the template itself is saved, and until this ref existed
|
|
179
|
+
* there was no way to tell "on screen" from "stored": a tab could be closed
|
|
180
|
+
* on an afternoon's work and say nothing about it.
|
|
181
|
+
*
|
|
182
|
+
* Null until the first load lands. Before that there is no baseline, and
|
|
183
|
+
* everything would compare as changed.
|
|
184
|
+
*/
|
|
185
|
+
const savedSnapshotRef = useRef(null);
|
|
186
|
+
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
|
187
|
+
/** How many questions differ from the stored version, for the indicator. */
|
|
188
|
+
const [unsavedCount, setUnsavedCount] = useState(0);
|
|
189
|
+
/**
|
|
190
|
+
* Whether the "this is not stored yet" notice has already been given since
|
|
191
|
+
* the last successful save. Once per unsaved streak: the first Apply is
|
|
192
|
+
* where the misunderstanding happens, and a toast on every Apply after that
|
|
193
|
+
* would train people to dismiss it without reading.
|
|
194
|
+
*/
|
|
195
|
+
const stagedNoticeShownRef = useRef(false);
|
|
196
|
+
/** Bumped whenever the baseline moves, so the comparison below re-runs. */
|
|
197
|
+
const [savedBaselineTick, setSavedBaselineTick] = useState(0);
|
|
171
198
|
const lockEnabled = Boolean(optimisticLock);
|
|
172
199
|
const lockSchemaVersion =
|
|
173
200
|
optimisticLock?.schemaVersion ?? DEFAULT_LOCK_SCHEMA_VERSION;
|
|
@@ -1358,8 +1385,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1358
1385
|
let errorMessage = validateField(field);
|
|
1359
1386
|
|
|
1360
1387
|
if (errorMessage === '') {
|
|
1388
|
+
const isNew = modalType.type === 'create';
|
|
1389
|
+
|
|
1361
1390
|
saveFieldData(field);
|
|
1362
1391
|
handleCloseModal();
|
|
1392
|
+
announceStagedChange(isNew);
|
|
1363
1393
|
} else {
|
|
1364
1394
|
displayErrorMessage(errorMessage);
|
|
1365
1395
|
}
|
|
@@ -1397,6 +1427,20 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1397
1427
|
(!field.options || field.options.length === 0)
|
|
1398
1428
|
) {
|
|
1399
1429
|
errors.push('Please add options for the dropdown.');
|
|
1430
|
+
} else if (
|
|
1431
|
+
/**
|
|
1432
|
+
* A checkbox with no options renders as a bare label with nothing
|
|
1433
|
+
* to tick. Harmless-looking in the builder, and fatal in the field:
|
|
1434
|
+
* marked required, it cannot be answered, so the form it belongs to
|
|
1435
|
+
* can never be submitted. Dropdowns have always been held to this;
|
|
1436
|
+
* checkboxes were simply missed.
|
|
1437
|
+
*/
|
|
1438
|
+
field.type === 'checkbox' &&
|
|
1439
|
+
(!field.options || field.options.length === 0)
|
|
1440
|
+
) {
|
|
1441
|
+
errors.push(
|
|
1442
|
+
'Please add at least one option for the checkbox — a checkbox with no options cannot be answered.'
|
|
1443
|
+
);
|
|
1400
1444
|
} else if (
|
|
1401
1445
|
field.type === 'table_text' &&
|
|
1402
1446
|
(!field.options || field.options.length === 0)
|
|
@@ -1814,6 +1858,101 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1814
1858
|
);
|
|
1815
1859
|
};
|
|
1816
1860
|
|
|
1861
|
+
/** The comparable form of the two things this screen actually stores. */
|
|
1862
|
+
const snapshotOf = (label, detail) =>
|
|
1863
|
+
JSON.stringify({ label: label ?? '', detail: detail ?? [] });
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* How many separate edits are waiting to be stored, counted by question.
|
|
1867
|
+
*
|
|
1868
|
+
* A number is worth more than "Unsaved changes" alone — it tells somebody
|
|
1869
|
+
* who stepped away how much they stand to lose. Counted by field id, so an
|
|
1870
|
+
* added, a reworded and a deleted question are three, while re-ordering the
|
|
1871
|
+
* same questions is none of them. That last case is not a miscount: the
|
|
1872
|
+
* order is written to the server the moment it changes, so a pure re-order
|
|
1873
|
+
* shows as dirty for only as long as that request is in flight, and the
|
|
1874
|
+
* wording below falls back to the unnumbered phrase.
|
|
1875
|
+
*/
|
|
1876
|
+
const countUnsavedChanges = (baseline, label, detail) => {
|
|
1877
|
+
let count = (baseline.label ?? '') === (label ?? '') ? 0 : 1;
|
|
1878
|
+
|
|
1879
|
+
const stored = new Map(
|
|
1880
|
+
(baseline.detail ?? []).map((field) => [
|
|
1881
|
+
field.id,
|
|
1882
|
+
JSON.stringify(field),
|
|
1883
|
+
])
|
|
1884
|
+
);
|
|
1885
|
+
const present = new Set();
|
|
1886
|
+
|
|
1887
|
+
(detail ?? []).forEach((field) => {
|
|
1888
|
+
present.add(field.id);
|
|
1889
|
+
|
|
1890
|
+
if (stored.get(field.id) !== JSON.stringify(field)) {
|
|
1891
|
+
count += 1;
|
|
1892
|
+
}
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
stored.forEach((_encoded, id) => {
|
|
1896
|
+
if (!present.has(id)) {
|
|
1897
|
+
count += 1;
|
|
1898
|
+
}
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
return count;
|
|
1902
|
+
};
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Say, at the moment it is least obvious, that Apply stored nothing.
|
|
1906
|
+
*
|
|
1907
|
+
* The question window closes and the new question appears in the list,
|
|
1908
|
+
* which looks exactly like a save. This is the one place where saying so
|
|
1909
|
+
* costs nothing and lands while the user is still looking.
|
|
1910
|
+
*/
|
|
1911
|
+
const announceStagedChange = (isNew) => {
|
|
1912
|
+
if (stagedNoticeShownRef.current) {
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
stagedNoticeShownRef.current = true;
|
|
1917
|
+
|
|
1918
|
+
toast.info(
|
|
1919
|
+
<div>
|
|
1920
|
+
{isNew ? 'Question added' : 'Question updated'} — this is on
|
|
1921
|
+
screen only. Tap <strong>Save Template</strong> to store it.
|
|
1922
|
+
</div>,
|
|
1923
|
+
{ toastId: 'fb-staged-change', autoClose: 6000 }
|
|
1924
|
+
);
|
|
1925
|
+
};
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* Re-anchor the unsaved-changes baseline on what the server now holds.
|
|
1929
|
+
*
|
|
1930
|
+
* Takes a partial: the sort endpoint writes `detail` alone and leaves the
|
|
1931
|
+
* label as it was, so rebuilding the whole baseline from the current
|
|
1932
|
+
* on-screen `data` would quietly adopt an unsaved rename as "stored".
|
|
1933
|
+
*/
|
|
1934
|
+
const markSaved = ({ label, detail } = {}) => {
|
|
1935
|
+
const previous = savedSnapshotRef.current
|
|
1936
|
+
? JSON.parse(savedSnapshotRef.current)
|
|
1937
|
+
: {};
|
|
1938
|
+
|
|
1939
|
+
savedSnapshotRef.current = snapshotOf(
|
|
1940
|
+
label === undefined ? previous.label : label,
|
|
1941
|
+
detail === undefined ? previous.detail : detail
|
|
1942
|
+
);
|
|
1943
|
+
|
|
1944
|
+
// The reminder is earned again by the next unsaved streak. Anyone who
|
|
1945
|
+
// has just stored their work has demonstrably understood the flow, and
|
|
1946
|
+
// will still be told the next time they walk into it.
|
|
1947
|
+
stagedNoticeShownRef.current = false;
|
|
1948
|
+
|
|
1949
|
+
// Never `setUnsavedChanges(false)` directly: a partial save can leave
|
|
1950
|
+
// something else still unsaved. Re-ordering writes `detail` and not the
|
|
1951
|
+
// label, so declaring the screen clean here would lose a pending
|
|
1952
|
+
// rename. Let the one comparison decide.
|
|
1953
|
+
setSavedBaselineTick((tick) => tick + 1);
|
|
1954
|
+
};
|
|
1955
|
+
|
|
1817
1956
|
/**
|
|
1818
1957
|
* "Somebody else got here first", in the only words that help.
|
|
1819
1958
|
*
|
|
@@ -1877,6 +2016,10 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1877
2016
|
|
|
1878
2017
|
if (res.data.error === '') {
|
|
1879
2018
|
await rememberDetailHash(data.id, savedDetail);
|
|
2019
|
+
markSaved({
|
|
2020
|
+
label: data.label,
|
|
2021
|
+
detail: savedDetail,
|
|
2022
|
+
});
|
|
1880
2023
|
|
|
1881
2024
|
toast.success(
|
|
1882
2025
|
"You have successfully updated the form's detail."
|
|
@@ -1912,7 +2055,31 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1912
2055
|
return;
|
|
1913
2056
|
}
|
|
1914
2057
|
|
|
2058
|
+
/**
|
|
2059
|
+
* Suppressing the shared handler's toast made this branch the
|
|
2060
|
+
* only voice the user has, so it must never be silent. It used
|
|
2061
|
+
* to `return` here, which turned an expired session (401
|
|
2062
|
+
* `Unauthenticated.`) — or any error body carrying neither
|
|
2063
|
+
* `errors` nor `message` — into a save that looked like it had
|
|
2064
|
+
* worked: nothing stored, and nothing said about it.
|
|
2065
|
+
*/
|
|
1915
2066
|
if (err?.response) {
|
|
2067
|
+
const status = err.response.status;
|
|
2068
|
+
const expired =
|
|
2069
|
+
status === 401 || message === 'Unauthenticated.';
|
|
2070
|
+
|
|
2071
|
+
toast.error(
|
|
2072
|
+
<div>
|
|
2073
|
+
<strong>Nothing was saved.</strong>{' '}
|
|
2074
|
+
{expired
|
|
2075
|
+
? 'Your session has expired. Sign in again in another tab, then come back and save — your changes are still on screen.'
|
|
2076
|
+
: `The server refused the save${
|
|
2077
|
+
status ? ` (error ${status})` : ''
|
|
2078
|
+
}. Your changes are still on screen — try again, and report this if it keeps happening.`}
|
|
2079
|
+
</div>,
|
|
2080
|
+
{ autoClose: false }
|
|
2081
|
+
);
|
|
2082
|
+
|
|
1916
2083
|
return;
|
|
1917
2084
|
}
|
|
1918
2085
|
}
|
|
@@ -1934,6 +2101,10 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1934
2101
|
// The version this tab is editing from. Everything the guard does
|
|
1935
2102
|
// is measured against this moment.
|
|
1936
2103
|
await rememberDetailHash(res.data?.id, res.data?.detail);
|
|
2104
|
+
markSaved({
|
|
2105
|
+
label: res.data?.label,
|
|
2106
|
+
detail: res.data?.detail,
|
|
2107
|
+
});
|
|
1937
2108
|
} catch (err) {
|
|
1938
2109
|
toast.error(`Error: ${err}`);
|
|
1939
2110
|
}
|
|
@@ -1961,6 +2132,10 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1961
2132
|
// here — but leaving the hash behind would make the next Save
|
|
1962
2133
|
// report a collision this tab caused itself.
|
|
1963
2134
|
await rememberDetailHash(data.id, savedDetail);
|
|
2135
|
+
|
|
2136
|
+
// Re-ordering is the one authoring action that reaches the server
|
|
2137
|
+
// on its own, so the unsaved-changes baseline has to follow it too.
|
|
2138
|
+
markSaved({ detail: savedDetail });
|
|
1964
2139
|
} catch (err) {
|
|
1965
2140
|
toast.error(`Error: ${err}`);
|
|
1966
2141
|
}
|
|
@@ -1992,6 +2167,59 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1992
2167
|
fetchRoles();
|
|
1993
2168
|
}, []);
|
|
1994
2169
|
|
|
2170
|
+
/**
|
|
2171
|
+
* Is what is on screen different from what is stored?
|
|
2172
|
+
*
|
|
2173
|
+
* Compared against the baseline rather than tracked per-action, so it
|
|
2174
|
+
* cannot drift: undoing a change by hand correctly reports "all changes
|
|
2175
|
+
* saved" again, and no authoring path can forget to raise the flag.
|
|
2176
|
+
*/
|
|
2177
|
+
useEffect(() => {
|
|
2178
|
+
if (savedSnapshotRef.current === null) {
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
const dirty =
|
|
2183
|
+
snapshotOf(data.label, data.detail) !== savedSnapshotRef.current;
|
|
2184
|
+
|
|
2185
|
+
setUnsavedChanges(dirty);
|
|
2186
|
+
// Only worth counting when something is outstanding, so a clean screen
|
|
2187
|
+
// never pays for parsing the baseline.
|
|
2188
|
+
setUnsavedCount(
|
|
2189
|
+
dirty
|
|
2190
|
+
? countUnsavedChanges(
|
|
2191
|
+
JSON.parse(savedSnapshotRef.current),
|
|
2192
|
+
data.label,
|
|
2193
|
+
data.detail
|
|
2194
|
+
)
|
|
2195
|
+
: 0
|
|
2196
|
+
);
|
|
2197
|
+
}, [data, savedBaselineTick]);
|
|
2198
|
+
|
|
2199
|
+
/**
|
|
2200
|
+
* The browser's own "leave site?" prompt, and only while there is something
|
|
2201
|
+
* to lose. Last line of defence for the failure this screen is prone to: a
|
|
2202
|
+
* question window's button reads as final, so a template can be closed in
|
|
2203
|
+
* the belief that its questions were filed.
|
|
2204
|
+
*/
|
|
2205
|
+
useEffect(() => {
|
|
2206
|
+
if (!unsavedChanges) {
|
|
2207
|
+
return undefined;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
const warnBeforeLeaving = (event) => {
|
|
2211
|
+
event.preventDefault();
|
|
2212
|
+
event.returnValue = '';
|
|
2213
|
+
|
|
2214
|
+
return '';
|
|
2215
|
+
};
|
|
2216
|
+
|
|
2217
|
+
window.addEventListener('beforeunload', warnBeforeLeaving);
|
|
2218
|
+
|
|
2219
|
+
return () =>
|
|
2220
|
+
window.removeEventListener('beforeunload', warnBeforeLeaving);
|
|
2221
|
+
}, [unsavedChanges]);
|
|
2222
|
+
|
|
1995
2223
|
/* ---------------------------------------------------------------- */
|
|
1996
2224
|
/* Guided help */
|
|
1997
2225
|
/* ---------------------------------------------------------------- */
|
|
@@ -2201,8 +2429,10 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
2201
2429
|
border: '#dc2626',
|
|
2202
2430
|
text: '#991b1b',
|
|
2203
2431
|
items: [
|
|
2204
|
-
'<strong>
|
|
2205
|
-
'To store the template, click <strong>
|
|
2432
|
+
'<strong>Apply</strong> inside a question window only adds it to the list on screen — nothing is stored yet',
|
|
2433
|
+
'To store the template, click <strong>Save Template</strong> (bottom right)',
|
|
2434
|
+
'While anything is waiting, the button turns amber and the label beside it counts what you would lose — the browser warns you as well if you try to leave',
|
|
2435
|
+
'<strong>Edit Form</strong> is only for renaming the template',
|
|
2206
2436
|
'Moving a question saves the whole template straight away, so the order is never lost',
|
|
2207
2437
|
'If someone else changed this template while you had it open, you are told nothing was saved — reload the page before saving again, or your changes would undo theirs',
|
|
2208
2438
|
],
|
|
@@ -2566,6 +2796,25 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
2566
2796
|
)}
|
|
2567
2797
|
</div>
|
|
2568
2798
|
<div className={styles.polActions}>
|
|
2799
|
+
{/* The only honest answer to "have my changes been
|
|
2800
|
+
stored?". Everything else on this screen edits a
|
|
2801
|
+
copy held in the browser. */}
|
|
2802
|
+
<span
|
|
2803
|
+
className={`${styles.saveState} ${
|
|
2804
|
+
unsavedChanges
|
|
2805
|
+
? styles.saveStateDirty
|
|
2806
|
+
: styles.saveStateClean
|
|
2807
|
+
}`}
|
|
2808
|
+
aria-live="polite"
|
|
2809
|
+
>
|
|
2810
|
+
{!unsavedChanges
|
|
2811
|
+
? 'All changes saved'
|
|
2812
|
+
: unsavedCount > 0
|
|
2813
|
+
? `${unsavedCount} unsaved change${
|
|
2814
|
+
unsavedCount === 1 ? '' : 's'
|
|
2815
|
+
}`
|
|
2816
|
+
: 'Unsaved changes'}
|
|
2817
|
+
</span>
|
|
2569
2818
|
{/* Nothing to author on a dynamic template, but
|
|
2570
2819
|
Edit Form stays so it can still be renamed. */}
|
|
2571
2820
|
{!dynamicFields && (
|
|
@@ -2582,6 +2831,29 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
2582
2831
|
>
|
|
2583
2832
|
Edit Form
|
|
2584
2833
|
</button>
|
|
2834
|
+
{/* Was reachable only through Edit Form — a window
|
|
2835
|
+
titled "Update Form Detail" showing nothing but
|
|
2836
|
+
the label, so the action that stores the
|
|
2837
|
+
questions looked like the one that renames the
|
|
2838
|
+
form. It belongs out here, beside the work. */}
|
|
2839
|
+
<button
|
|
2840
|
+
className={`${styles.btn} ${
|
|
2841
|
+
styles.btnPrimary
|
|
2842
|
+
} ${
|
|
2843
|
+
unsavedChanges
|
|
2844
|
+
? styles.btnSaveDirty
|
|
2845
|
+
: styles.btnSaveClean
|
|
2846
|
+
}`}
|
|
2847
|
+
onClick={handleSubmit}
|
|
2848
|
+
data-tooltip-id="system-tooltip"
|
|
2849
|
+
data-tooltip-content={
|
|
2850
|
+
unsavedChanges
|
|
2851
|
+
? 'You have changes that are only on screen. Tap to store them.'
|
|
2852
|
+
: 'Everything on this screen is stored.'
|
|
2853
|
+
}
|
|
2854
|
+
>
|
|
2855
|
+
Save Template
|
|
2856
|
+
</button>
|
|
2585
2857
|
</div>
|
|
2586
2858
|
</div>
|
|
2587
2859
|
</div>
|
|
@@ -3399,11 +3671,23 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
3399
3671
|
<div
|
|
3400
3672
|
className={`${styles.formItem} ${styles.fwItem} ${styles.lastItem}`}
|
|
3401
3673
|
>
|
|
3674
|
+
{/* This window has never stored
|
|
3675
|
+
anything — it edits the copy held in
|
|
3676
|
+
the browser. Calling its button
|
|
3677
|
+
"Save" was the whole reason people
|
|
3678
|
+
left the screen believing their work
|
|
3679
|
+
had been filed. */}
|
|
3680
|
+
<p className={styles.applyHint}>
|
|
3681
|
+
Adds this question to the list on
|
|
3682
|
+
screen. Use{' '}
|
|
3683
|
+
<strong>Save Template</strong> to
|
|
3684
|
+
store it.
|
|
3685
|
+
</p>
|
|
3402
3686
|
<button
|
|
3403
3687
|
className={`${styles.btn}`}
|
|
3404
3688
|
onClick={handleSaveField}
|
|
3405
3689
|
>
|
|
3406
|
-
|
|
3690
|
+
Apply
|
|
3407
3691
|
</button>
|
|
3408
3692
|
</div>
|
|
3409
3693
|
</form>
|
|
@@ -643,7 +643,8 @@ select:not(:placeholder-shown) + .fi__span {
|
|
|
643
643
|
overflow: visible;
|
|
644
644
|
position: relative;
|
|
645
645
|
z-index: 100;
|
|
646
|
-
box-shadow:
|
|
646
|
+
box-shadow:
|
|
647
|
+
0 20px 60px rgba(0, 0, 0, 0.15),
|
|
647
648
|
0 0 0 1px rgba(var(--primary-rgb), 0.06);
|
|
648
649
|
}
|
|
649
650
|
|
|
@@ -786,9 +787,6 @@ select:not(:placeholder-shown) + .fi__span {
|
|
|
786
787
|
|
|
787
788
|
/* Adding btn class */
|
|
788
789
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
790
|
/* Floating action buttons */
|
|
793
791
|
.polActions {
|
|
794
792
|
position: fixed;
|
|
@@ -801,9 +799,11 @@ select:not(:placeholder-shown) + .fi__span {
|
|
|
801
799
|
background: rgba(255, 255, 255, 0.95);
|
|
802
800
|
padding: 10px 14px;
|
|
803
801
|
border-radius: 12px;
|
|
804
|
-
box-shadow:
|
|
802
|
+
box-shadow:
|
|
803
|
+
0 4px 20px rgba(0, 0, 0, 0.12),
|
|
805
804
|
0 0 0 1px rgba(var(--primary-rgb), 0.08);
|
|
806
805
|
backdrop-filter: blur(8px);
|
|
806
|
+
align-items: center;
|
|
807
807
|
|
|
808
808
|
button {
|
|
809
809
|
display: block;
|
|
@@ -814,6 +814,128 @@ select:not(:placeholder-shown) + .fi__span {
|
|
|
814
814
|
}
|
|
815
815
|
}
|
|
816
816
|
|
|
817
|
+
/* Stored or not — the one thing this screen never used to say out loud. */
|
|
818
|
+
.saveState {
|
|
819
|
+
display: inline-flex;
|
|
820
|
+
align-items: center;
|
|
821
|
+
gap: 6px;
|
|
822
|
+
padding: 0 6px;
|
|
823
|
+
font-size: 0.8rem;
|
|
824
|
+
font-weight: 600;
|
|
825
|
+
white-space: nowrap;
|
|
826
|
+
|
|
827
|
+
&::before {
|
|
828
|
+
content: '';
|
|
829
|
+
width: 8px;
|
|
830
|
+
height: 8px;
|
|
831
|
+
border-radius: 50%;
|
|
832
|
+
background: currentColor;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/* Amber, pilled and bolder than its calm twin. The indicator, the dot and the
|
|
837
|
+
Save Template button all sit on the same warning hue on purpose: one state,
|
|
838
|
+
said three times, so it cannot be read as decoration. */
|
|
839
|
+
.saveStateDirty {
|
|
840
|
+
color: #92400e;
|
|
841
|
+
font-weight: 700;
|
|
842
|
+
padding: 3px 10px;
|
|
843
|
+
border-radius: 999px;
|
|
844
|
+
background: rgba(245, 158, 11, 0.16);
|
|
845
|
+
border: 1px solid rgba(180, 83, 9, 0.35);
|
|
846
|
+
|
|
847
|
+
&::before {
|
|
848
|
+
background: #d97706;
|
|
849
|
+
box-shadow: 0 0 0 0 rgba(217, 119, 6, 0.6);
|
|
850
|
+
animation: fbSaveDot 2s ease-in-out infinite;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
.saveStateClean {
|
|
855
|
+
color: #15803d;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/* The save that actually writes, weighted so it reads as the end of the job. */
|
|
859
|
+
.btnPrimary {
|
|
860
|
+
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.35);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/* Nothing to save: present and findable, but not competing for attention. */
|
|
864
|
+
.btnSaveClean {
|
|
865
|
+
opacity: 0.9;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Something to save. Amber overrides the shared primary fill, and the ring
|
|
870
|
+
* breathes so the button reads as waiting on the user rather than as one more
|
|
871
|
+
* blue button in a row of blue buttons.
|
|
872
|
+
*/
|
|
873
|
+
.btnSaveDirty {
|
|
874
|
+
background: #d97706 !important;
|
|
875
|
+
border-color: #b45309 !important;
|
|
876
|
+
color: #fff !important;
|
|
877
|
+
font-weight: 700;
|
|
878
|
+
box-shadow:
|
|
879
|
+
0 0 0 2px rgba(217, 119, 6, 0.45),
|
|
880
|
+
0 4px 14px rgba(180, 83, 9, 0.35);
|
|
881
|
+
animation: fbSaveNudge 2s ease-in-out infinite;
|
|
882
|
+
|
|
883
|
+
&:hover:not(:disabled) {
|
|
884
|
+
background: #b45309 !important;
|
|
885
|
+
border-color: #92400e !important;
|
|
886
|
+
color: #fff !important;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
@keyframes fbSaveNudge {
|
|
891
|
+
0%,
|
|
892
|
+
100% {
|
|
893
|
+
box-shadow:
|
|
894
|
+
0 0 0 2px rgba(217, 119, 6, 0.45),
|
|
895
|
+
0 4px 14px rgba(180, 83, 9, 0.35);
|
|
896
|
+
transform: scale(1);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
50% {
|
|
900
|
+
box-shadow:
|
|
901
|
+
0 0 0 7px rgba(217, 119, 6, 0),
|
|
902
|
+
0 6px 18px rgba(180, 83, 9, 0.45);
|
|
903
|
+
transform: scale(1.035);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
@keyframes fbSaveDot {
|
|
908
|
+
0%,
|
|
909
|
+
100% {
|
|
910
|
+
box-shadow: 0 0 0 0 rgba(217, 119, 6, 0.6);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
50% {
|
|
914
|
+
box-shadow: 0 0 0 5px rgba(217, 119, 6, 0);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/* Movement is the enhancement, never the message: the amber fill, the ring and
|
|
919
|
+
the wording all survive on their own for anyone who asked for stillness. */
|
|
920
|
+
@media (prefers-reduced-motion: reduce) {
|
|
921
|
+
.btnSaveDirty,
|
|
922
|
+
.saveStateDirty::before {
|
|
923
|
+
animation: none;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/* Sits under the question window's Apply, where the misunderstanding happens. */
|
|
928
|
+
.applyHint {
|
|
929
|
+
flex: 1;
|
|
930
|
+
align-self: center;
|
|
931
|
+
margin: 0;
|
|
932
|
+
padding-right: 1rem;
|
|
933
|
+
font-size: 0.78rem;
|
|
934
|
+
line-height: 1.35;
|
|
935
|
+
color: var(--text-color-light, #6b7280);
|
|
936
|
+
text-align: left;
|
|
937
|
+
}
|
|
938
|
+
|
|
817
939
|
.fwItem {
|
|
818
940
|
flex-basis: 100%;
|
|
819
941
|
}
|
|
@@ -1337,6 +1459,12 @@ select:not(:placeholder-shown) + .fi__span {
|
|
|
1337
1459
|
.dragHandle {
|
|
1338
1460
|
color: var(--primary-color) !important;
|
|
1339
1461
|
cursor: grab;
|
|
1462
|
+
/* Same reason as .cGrip: on iPadOS the browser treats a press-and-move on
|
|
1463
|
+
this handle as a scroll unless the element opts out, so the drag never
|
|
1464
|
+
activates and reordering is silently unavailable. */
|
|
1465
|
+
touch-action: none;
|
|
1466
|
+
-webkit-user-select: none;
|
|
1467
|
+
user-select: none;
|
|
1340
1468
|
padding: 4px;
|
|
1341
1469
|
border-radius: 6px;
|
|
1342
1470
|
background: white;
|
|
@@ -2047,7 +2175,8 @@ select:not(:placeholder-shown) + .fi__span {
|
|
|
2047
2175
|
border-radius: 12px;
|
|
2048
2176
|
padding: 1.25rem 1.5rem;
|
|
2049
2177
|
width: 280px;
|
|
2050
|
-
box-shadow:
|
|
2178
|
+
box-shadow:
|
|
2179
|
+
0 16px 48px rgba(0, 0, 0, 0.18),
|
|
2051
2180
|
0 0 0 1px rgba(var(--primary-rgb), 0.08);
|
|
2052
2181
|
|
|
2053
2182
|
h3 {
|
|
@@ -2472,7 +2601,6 @@ select:not(:placeholder-shown) + .fi__span {
|
|
|
2472
2601
|
margin-right: -1.15rem !important;
|
|
2473
2602
|
}
|
|
2474
2603
|
|
|
2475
|
-
|
|
2476
2604
|
/* The header and search span both columns; only the rail and canvas sit
|
|
2477
2605
|
side by side. */
|
|
2478
2606
|
|
|
@@ -2622,9 +2750,6 @@ select:not(:placeholder-shown) + .fi__span {
|
|
|
2622
2750
|
|
|
2623
2751
|
/* ---- compact rows ---- */
|
|
2624
2752
|
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
2753
|
.duplicateBtn {
|
|
2629
2754
|
display: flex;
|
|
2630
2755
|
align-items: center;
|
|
@@ -2705,6 +2830,12 @@ select:not(:placeholder-shown) + .fi__span {
|
|
|
2705
2830
|
align-items: center;
|
|
2706
2831
|
color: rgba(var(--paragraph-rgb), 0.4);
|
|
2707
2832
|
cursor: grab;
|
|
2833
|
+
/* dnd-kit's PointerSensor cannot start a drag from an element the browser
|
|
2834
|
+
has already claimed for scrolling. Without this the grip does nothing at
|
|
2835
|
+
all on a touch screen — the page just scrolls under the finger. */
|
|
2836
|
+
touch-action: none;
|
|
2837
|
+
-webkit-user-select: none;
|
|
2838
|
+
user-select: none;
|
|
2708
2839
|
|
|
2709
2840
|
&:active {
|
|
2710
2841
|
cursor: grabbing;
|