@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.2.1",
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>Save</strong> inside a question window only adds it to the list on screen — nothing is stored yet',
2205
- 'To store the template, click <strong>Edit Form</strong> (bottom right) and then <strong>Save</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
- Save
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: 0 20px 60px rgba(0, 0, 0, 0.15),
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: 0 4px 20px rgba(0, 0, 0, 0.12),
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: 0 16px 48px rgba(0, 0, 0, 0.18),
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;