@visns-studio/visns-components 6.3.0 → 6.3.2

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.3.0",
94
+ "version": "6.3.2",
95
95
  "description": "Various packages to assist in the development of our Custom Applications.",
96
96
  "main": "src/index.js",
97
97
  "files": [
@@ -4630,44 +4630,37 @@ const DataGrid = forwardRef(
4630
4630
 
4631
4631
  {/* Opt-in colour legend (config key `legend`: array of
4632
4632
  {colour, label}); entries without a colour render as
4633
- plain lead-in text */}
4633
+ plain lead-in text.
4634
+
4635
+ Styling lives in DataGrid.module.scss (`.legend` and
4636
+ friends) rather than inline, so a consuming project can
4637
+ actually reach it — see the notes there. The only inline
4638
+ style left is the swatch's own fill, which is data from
4639
+ the config rather than a design decision. */}
4634
4640
  {Array.isArray(legend) && legend.length > 0 && (
4635
- <div
4636
- style={{
4637
- display: 'flex',
4638
- flexWrap: 'wrap',
4639
- alignItems: 'center',
4640
- gap: '14px',
4641
- padding: '6px 4px 8px',
4642
- fontSize: '0.8rem',
4643
- color: '#555',
4644
- }}
4645
- >
4646
- {legend.map((item, index) => (
4647
- <span
4648
- key={index}
4649
- style={{
4650
- display: 'inline-flex',
4651
- alignItems: 'center',
4652
- gap: '6px',
4653
- }}
4654
- >
4655
- {(item.colour || item.color) && (
4656
- <span
4657
- style={{
4658
- width: '14px',
4659
- height: '14px',
4660
- borderRadius: '3px',
4661
- backgroundColor:
4662
- item.colour || item.color,
4663
- display: 'inline-block',
4664
- flexShrink: 0,
4665
- }}
4666
- />
4667
- )}
4668
- {item.label}
4669
- </span>
4670
- ))}
4641
+ <div className={styles.legend}>
4642
+ {legend.map((item, index) => {
4643
+ const swatch = item.colour || item.color;
4644
+
4645
+ return (
4646
+ <span
4647
+ key={index}
4648
+ className={
4649
+ swatch
4650
+ ? styles.legendItem
4651
+ : `${styles.legendItem} ${styles.legendLead}`
4652
+ }
4653
+ >
4654
+ {swatch && (
4655
+ <span
4656
+ className={styles.legendSwatch}
4657
+ style={{ backgroundColor: swatch }}
4658
+ />
4659
+ )}
4660
+ {item.label}
4661
+ </span>
4662
+ );
4663
+ })}
4671
4664
  </div>
4672
4665
  )}
4673
4666
 
@@ -184,6 +184,15 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
184
184
  */
185
185
  const savedSnapshotRef = useRef(null);
186
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);
187
196
  /** Bumped whenever the baseline moves, so the comparison below re-runs. */
188
197
  const [savedBaselineTick, setSavedBaselineTick] = useState(0);
189
198
  const lockEnabled = Boolean(optimisticLock);
@@ -1376,8 +1385,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1376
1385
  let errorMessage = validateField(field);
1377
1386
 
1378
1387
  if (errorMessage === '') {
1388
+ const isNew = modalType.type === 'create';
1389
+
1379
1390
  saveFieldData(field);
1380
1391
  handleCloseModal();
1392
+ announceStagedChange(isNew);
1381
1393
  } else {
1382
1394
  displayErrorMessage(errorMessage);
1383
1395
  }
@@ -1850,6 +1862,68 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1850
1862
  const snapshotOf = (label, detail) =>
1851
1863
  JSON.stringify({ label: label ?? '', detail: detail ?? [] });
1852
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
+
1853
1927
  /**
1854
1928
  * Re-anchor the unsaved-changes baseline on what the server now holds.
1855
1929
  *
@@ -1867,6 +1941,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1867
1941
  detail === undefined ? previous.detail : detail
1868
1942
  );
1869
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
+
1870
1949
  // Never `setUnsavedChanges(false)` directly: a partial save can leave
1871
1950
  // something else still unsaved. Re-ordering writes `detail` and not the
1872
1951
  // label, so declaring the screen clean here would lose a pending
@@ -2100,8 +2179,20 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
2100
2179
  return;
2101
2180
  }
2102
2181
 
2103
- setUnsavedChanges(
2104
- snapshotOf(data.label, data.detail) !== savedSnapshotRef.current
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
2105
2196
  );
2106
2197
  }, [data, savedBaselineTick]);
2107
2198
 
@@ -2340,7 +2431,7 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
2340
2431
  items: [
2341
2432
  '<strong>Apply</strong> inside a question window only adds it to the list on screen — nothing is stored yet',
2342
2433
  'To store the template, click <strong>Save Template</strong> (bottom right)',
2343
- 'The label beside those buttons reads <strong>Unsaved changes</strong> until you do, and the browser warns you if you try to leave',
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',
2344
2435
  '<strong>Edit Form</strong> is only for renaming the template',
2345
2436
  'Moving a question saves the whole template straight away, so the order is never lost',
2346
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',
@@ -2714,10 +2805,15 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
2714
2805
  ? styles.saveStateDirty
2715
2806
  : styles.saveStateClean
2716
2807
  }`}
2808
+ aria-live="polite"
2717
2809
  >
2718
- {unsavedChanges
2719
- ? 'Unsaved changes'
2720
- : 'All changes saved'}
2810
+ {!unsavedChanges
2811
+ ? 'All changes saved'
2812
+ : unsavedCount > 0
2813
+ ? `${unsavedCount} unsaved change${
2814
+ unsavedCount === 1 ? '' : 's'
2815
+ }`
2816
+ : 'Unsaved changes'}
2721
2817
  </span>
2722
2818
  {/* Nothing to author on a dynamic template, but
2723
2819
  Edit Form stays so it can still be renamed. */}
@@ -2741,10 +2837,20 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
2741
2837
  questions looked like the one that renames the
2742
2838
  form. It belongs out here, beside the work. */}
2743
2839
  <button
2744
- className={`${styles.btn} ${styles.btnPrimary}`}
2840
+ className={`${styles.btn} ${
2841
+ styles.btnPrimary
2842
+ } ${
2843
+ unsavedChanges
2844
+ ? styles.btnSaveDirty
2845
+ : styles.btnSaveClean
2846
+ }`}
2745
2847
  onClick={handleSubmit}
2746
2848
  data-tooltip-id="system-tooltip"
2747
- data-tooltip-content="Store this template. Question windows only stage changes on screen."
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
+ }
2748
2854
  >
2749
2855
  Save Template
2750
2856
  </button>
@@ -526,6 +526,94 @@
526
526
  justify-content: flex-end;
527
527
  }
528
528
 
529
+ /* ============================================================================
530
+ Colour legend
531
+ ----------------------------------------------------------------------------
532
+ The opt-in caption above the grid (config key `legend`: an array of
533
+ `{ colour | color, label }`, where an entry carrying no colour renders as
534
+ lead-in text introducing the swatches after it).
535
+
536
+ This was inline styles on the elements themselves — `fontSize: '0.8rem'`,
537
+ `color: '#555'`, a hardcoded 14px swatch, `padding: '6px 4px 8px'`. Inline
538
+ styles are unreachable from a consuming project's stylesheet, so the one
539
+ thing a legend needs to be — tuned to the app it sits in — was the one
540
+ thing it could not be. It was also set off any scale in the library: 0.8rem
541
+ falls between two steps of the type ramp, and #555 is a grey that appears
542
+ nowhere else.
543
+
544
+ It is a caption for the table below it, so it is set as one: the same
545
+ `--font-size-xs`, `--muted-color` and `--font-weight-semibold` the grid's
546
+ own header row uses, so the two label rows stacked above the data read as
547
+ one family. It deliberately stops short of that header's uppercase and
548
+ 0.06em tracking — legend labels are sentences ("No Goods Received entered
549
+ yet"), not column names, and setting a sentence in caps would undo the
550
+ quiet the rest of this is after.
551
+
552
+ Density hooks, following the `--nav-*` set documented at the top of
553
+ Navigation.module.scss: every declaration below is written
554
+ `var(--hook, <the value it always had>)`, so an unset hook is a no-op and a
555
+ project retunes from `:root` with no fork, no prop and no class of its own.
556
+ The plant-floor tablet case is the one that needs them — 12px is small when
557
+ the screen is at arm's length on a bench.
558
+
559
+ --grid-legend-font-size label type size.
560
+ default: var(--font-size-xs, 0.75rem)
561
+ --grid-legend-gap space between entries along a line.
562
+ default: var(--spacing-md, 1rem)
563
+ --grid-legend-swatch-size the colour chip. default: 0.875rem (14px)
564
+ ========================================================================= */
565
+
566
+ .legend {
567
+ display: flex;
568
+ flex-wrap: wrap;
569
+ align-items: center;
570
+ /* Row gap and column gap differ on purpose: a legend that wraps (despatch
571
+ runs to seven entries) needs its lines closer together than its entries
572
+ are along a line, or the wrapped rows read as separate captions. */
573
+ gap: var(--spacing-sm, 0.5rem)
574
+ var(--grid-legend-gap, var(--spacing-md, 1rem));
575
+ /* A caption belongs to the thing it describes, so it sits nearer the grid
576
+ than the toolbar row above it — which is also what stops it crowding
577
+ that row, the complaint this change came from. No horizontal padding:
578
+ `.dataGridContainer` has none either, so the caption starts on the
579
+ grid's own left edge rather than floating 4px off it. */
580
+ margin: var(--spacing-md, 1rem) 0 var(--spacing-sm, 0.5rem);
581
+ padding: 0;
582
+ font-size: var(--grid-legend-font-size, var(--font-size-xs, 0.75rem));
583
+ line-height: 1.4;
584
+ color: var(--muted-color, #6b7688);
585
+ }
586
+
587
+ .legendItem {
588
+ display: inline-flex;
589
+ align-items: center;
590
+ gap: var(--spacing-xs, 0.25rem);
591
+ }
592
+
593
+ /* The lead-in entry: a legend line with no colour of its own, labelling the
594
+ swatches that follow. Weight and a darker ink rather than a different size,
595
+ so it introduces the row without breaking its line. */
596
+ .legendLead {
597
+ font-weight: var(--font-weight-semibold, 600);
598
+ color: var(--paragraph-color, #374151);
599
+ }
600
+
601
+ .legendSwatch {
602
+ width: var(--grid-legend-swatch-size, 0.875rem);
603
+ height: var(--grid-legend-swatch-size, 0.875rem);
604
+ flex-shrink: 0;
605
+ display: inline-block;
606
+ border-radius: var(--radius-sm, 6px);
607
+ /* These are row tints, so they are pale by design — #FFEAEA has no edge
608
+ of its own against a white page and the swatch dissolves into it. A
609
+ low-alpha ink hairline gives every chip its shape back while staying
610
+ invisible against the darker ones. `--border-color` is the wrong token
611
+ here: it is tuned to be barely there on a white surface, which is
612
+ exactly where it is needed most. */
613
+ border: 1px solid rgba(var(--paragraph-color-rgb, 34, 30, 51), 0.18);
614
+ box-sizing: border-box;
615
+ }
616
+
529
617
  /* Date field styling for uniform width */
530
618
  :global {
531
619
  // Target date cells specifically
@@ -833,8 +833,22 @@ select:not(:placeholder-shown) + .fi__span {
833
833
  }
834
834
  }
835
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. */
836
839
  .saveStateDirty {
837
- color: #b45309;
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
+ }
838
852
  }
839
853
 
840
854
  .saveStateClean {
@@ -846,6 +860,70 @@ select:not(:placeholder-shown) + .fi__span {
846
860
  box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.35);
847
861
  }
848
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
+
849
927
  /* Sits under the question window's Apply, where the misunderstanding happens. */
850
928
  .applyHint {
851
929
  flex: 1;