@visns-studio/visns-components 6.4.4 → 6.5.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.4.4",
94
+ "version": "6.5.1",
95
95
  "description": "Various packages to assist in the development of our Custom Applications.",
96
96
  "main": "src/index.js",
97
97
  "files": [
@@ -50,6 +50,32 @@ const NAV_ICONS = {
50
50
  users: Users,
51
51
  };
52
52
 
53
+ // The icons an account action can wear, named by a settings entry's `icon`
54
+ // key. Module scope because two surfaces read it now: the header's action
55
+ // chips and — when a project opts in — the sidebar's pinned action rows.
56
+ const SETTING_ICONS = {
57
+ bug: Bug,
58
+ calendar: Calendar,
59
+ draft: FileText,
60
+ image: Image,
61
+ peopleGroup: Users,
62
+ person: User,
63
+ phone: Phone,
64
+ shield: Shield,
65
+ signOut: LogOut,
66
+ settings: Settings,
67
+ search: Search,
68
+ userSearch: UserSearch,
69
+ clipboardCheck: ClipboardCheck,
70
+ };
71
+
72
+ // A rail row carries a visible label. In the header these actions were icon
73
+ // chips with a tooltip, so a settings entry is allowed to have no label of
74
+ // its own — the bell in particular usually doesn't.
75
+ const RAIL_ACTION_LABELS = {
76
+ notifications: 'Notifications',
77
+ };
78
+
53
79
  const SearchComponent = ({
54
80
  navigate,
55
81
  search,
@@ -270,21 +296,7 @@ function Navigation({
270
296
  };
271
297
 
272
298
  const renderSetting = (n) => {
273
- const iconComponents = {
274
- bug: Bug,
275
- calendar: Calendar,
276
- draft: FileText,
277
- image: Image,
278
- peopleGroup: Users,
279
- person: User,
280
- phone: Phone,
281
- shield: Shield,
282
- signOut: LogOut,
283
- settings: Settings,
284
- search: Search,
285
- userSearch: UserSearch,
286
- clipboardCheck: ClipboardCheck,
287
- };
299
+ const iconComponents = SETTING_ICONS;
288
300
 
289
301
  // Check if the setting's URL matches the current page
290
302
  const isActive = n.url && n.url === `/${currentPage}`;
@@ -356,6 +368,93 @@ function Navigation({
356
368
  }
357
369
  };
358
370
 
371
+ /**
372
+ * The same account actions, drawn as rows for the sidebar rail.
373
+ *
374
+ * Opt-in only — see `actionsInSidebar` below. Every action keeps the
375
+ * behaviour it had in the header: the same logout handler, the same
376
+ * links, the same mounted components (notifications, account switcher),
377
+ * so nothing here re-implements a chip. What changes is the shape: a
378
+ * full-width row wearing the rail's own language, icon then label, since
379
+ * a 36px chip in a 216px column reads as a stray button.
380
+ *
381
+ * Components mounted here draw their own trigger and can't be handed a
382
+ * label, so the row publishes one as `--nav-action-label` for their
383
+ * stylesheet to place. See Notification.module.scss / SwitchAccount.
384
+ */
385
+ const renderRailSetting = (n) => {
386
+ const isActive = n.url && n.url === `/${currentPage}`;
387
+ const activeClass = isActive ? styles.active : '';
388
+ const label = n.label || RAIL_ACTION_LABELS[n.id] || '';
389
+ // A CSS string, quoted and escaped, for `content`.
390
+ const labelVar = { '--nav-action-label': JSON.stringify(label) };
391
+ const IconComponent = SETTING_ICONS[n.icon];
392
+
393
+ if (n.id === 'notifications') {
394
+ return (
395
+ <li style={labelVar}>
396
+ <Notification setSystemAuth={setSystemAuth} />
397
+ </li>
398
+ );
399
+ } else if (n.id === 'businessCardOcr') {
400
+ return (
401
+ <li>
402
+ <button
403
+ onClick={() => setShowBusinessCardOcr(true)}
404
+ className={activeClass}
405
+ type="button"
406
+ >
407
+ <CreditCard size={19} strokeWidth={2} />
408
+ <span>{label}</span>
409
+ </button>
410
+ </li>
411
+ );
412
+ } else if (n.id === 'switchAccount') {
413
+ return (
414
+ <li style={labelVar}>
415
+ <SwitchAccount
416
+ setSystemAuth={setSystemAuth}
417
+ setting={n}
418
+ userProfile={userProfile}
419
+ />
420
+ </li>
421
+ );
422
+ } else if (n.id === 'logout') {
423
+ return (
424
+ <li>
425
+ <button
426
+ onClick={handleLogout}
427
+ className={activeClass}
428
+ type="button"
429
+ >
430
+ {IconComponent && (
431
+ <IconComponent size={19} strokeWidth={2} />
432
+ )}
433
+ <span>{label}</span>
434
+ </button>
435
+ </li>
436
+ );
437
+ }
438
+
439
+ return (
440
+ <li>
441
+ <Link
442
+ to={n.url}
443
+ className={`${activeClass}`}
444
+ target={n.target ? n.target : '_self'}
445
+ // The rail is the mobile drawer below 1024px; navigating
446
+ // from it has to close the drawer, as the nav list does.
447
+ onClick={() => setOpen(false)}
448
+ >
449
+ {IconComponent && (
450
+ <IconComponent size={19} strokeWidth={2} />
451
+ )}
452
+ <span>{label}</span>
453
+ </Link>
454
+ </li>
455
+ );
456
+ };
457
+
359
458
  const [, setActiveDropdown] = useState(null); // We only need the setter
360
459
  const navWrapRef = useRef(null);
361
460
 
@@ -704,14 +803,60 @@ function Navigation({
704
803
  };
705
804
  }, [debugIcons]);
706
805
 
806
+ /**
807
+ * `navConfig.actionsPlacement === 'sidebar'` moves the account actions
808
+ * out of the 50px content header and pins them to the bottom of the
809
+ * rail, which is otherwise empty space, so the page gets the whole
810
+ * height of the glass. Every project that publishes nothing — or
811
+ * 'header' — renders exactly what it rendered before.
812
+ */
813
+ const actionsInSidebar =
814
+ (layout === 'simple' || layout === 'cms') &&
815
+ navConfig.actionsPlacement === 'sidebar';
816
+ const searchEnabled = navConfig.search?.enable === true;
817
+ /**
818
+ * With the actions gone the header holds a search field the project may
819
+ * not have enabled and a hamburger that only exists below 1024px. In
820
+ * that case it stays in the markup — the drawer is anchored under it and
821
+ * offsets itself by its 50px — but desktop drops it, and the main
822
+ * container starts at the top of the viewport.
823
+ */
824
+ const headerIsMobileOnly = actionsInSidebar && !searchEnabled;
825
+
826
+ // Built only when a project asks for it, so the default path does no
827
+ // work it then throws away.
828
+ const railActions = !actionsInSidebar ? null : (
829
+ <div
830
+ className={styles['rail-actions']}
831
+ /* Read by the stylesheets of the components mounted in here —
832
+ the notification panel and the account switcher list both
833
+ anchor to a header chip by default, which from a rail at the
834
+ left edge would open off-screen. */
835
+ data-nav-actions="sidebar"
836
+ ref={actionItemsRef}
837
+ >
838
+ <ul>
839
+ {navData.settings.map((setting, settingKey) =>
840
+ setting.hidden !== true && setting.permission === true ? (
841
+ <React.Fragment key={`rail-setting-${settingKey}`}>
842
+ {renderRailSetting(setting)}
843
+ </React.Fragment>
844
+ ) : null
845
+ )}
846
+ </ul>
847
+ </div>
848
+ );
849
+
707
850
  return layout === 'simple' || layout === 'cms' ? (
708
851
  <div className={styles.cwrap}>
709
852
  <aside
710
- className={
711
- isOpen
712
- ? `${styles['aside--cms']} ${styles['aside--cms--open']}`
713
- : `${styles['aside--cms']}`
714
- }
853
+ className={[
854
+ styles['aside--cms'],
855
+ isOpen ? styles['aside--cms--open'] : '',
856
+ actionsInSidebar ? styles['aside--cms--rail'] : '',
857
+ ]
858
+ .filter(Boolean)
859
+ .join(' ')}
715
860
  >
716
861
  <div className={styles.logo}>
717
862
  <Link to="/">
@@ -733,6 +878,7 @@ function Navigation({
733
878
  </ul>
734
879
  </nav>
735
880
  </div>
881
+ {railActions}
736
882
  </aside>
737
883
  {isOpen ? (
738
884
  <button
@@ -742,8 +888,20 @@ function Navigation({
742
888
  onClick={() => setOpen(false)}
743
889
  />
744
890
  ) : null}
745
- <main className={styles.content}>
746
- <div className={styles['content-header']}>
891
+ <main
892
+ className={
893
+ headerIsMobileOnly
894
+ ? `${styles.content} ${styles['content--full']}`
895
+ : styles.content
896
+ }
897
+ >
898
+ <div
899
+ className={
900
+ headerIsMobileOnly
901
+ ? `${styles['content-header']} ${styles['content-header--mobile']}`
902
+ : styles['content-header']
903
+ }
904
+ >
747
905
  {navConfig.search?.enable === true && (
748
906
  <SearchComponent
749
907
  navigate={navigate}
@@ -774,25 +932,27 @@ function Navigation({
774
932
  <span></span>
775
933
  </button>
776
934
  </div>
777
- <div
778
- className={`${styles['hactions-alternate']} ${
779
- debugIcons ? styles['debug-icons'] : ''
780
- }`}
781
- ref={actionItemsRef}
782
- >
783
- <ul>
784
- {navData.settings.map((setting, settingKey) =>
785
- setting.hidden !== true &&
786
- setting.permission === true ? (
787
- <React.Fragment
788
- key={`setting-${settingKey}`}
789
- >
790
- {renderSetting(setting)}
791
- </React.Fragment>
792
- ) : null
793
- )}
794
- </ul>
795
- </div>
935
+ {actionsInSidebar ? null : (
936
+ <div
937
+ className={`${styles['hactions-alternate']} ${
938
+ debugIcons ? styles['debug-icons'] : ''
939
+ }`}
940
+ ref={actionItemsRef}
941
+ >
942
+ <ul>
943
+ {navData.settings.map((setting, settingKey) =>
944
+ setting.hidden !== true &&
945
+ setting.permission === true ? (
946
+ <React.Fragment
947
+ key={`setting-${settingKey}`}
948
+ >
949
+ {renderSetting(setting)}
950
+ </React.Fragment>
951
+ ) : null
952
+ )}
953
+ </ul>
954
+ </div>
955
+ )}
796
956
  </div>
797
957
  {children}
798
958
  </main>
@@ -707,7 +707,10 @@
707
707
  .logo {
708
708
  width: 100%;
709
709
  position: relative;
710
- background: rgba(var(--tertiary-color-rgb), 0.95);
710
+ /* Near-white by default (the rendering every project has today).
711
+ A project whose page background is the same colour can merge the
712
+ plate into the rail instead: `--nav-logo-bg: var(--nav-bg)`. */
713
+ background: var(--nav-logo-bg, rgba(var(--tertiary-color-rgb), 0.95));
711
714
  height: 50px;
712
715
  display: flex;
713
716
  flex-wrap: nowrap;
@@ -870,6 +873,145 @@
870
873
  }
871
874
  }
872
875
 
876
+ /* ----------------------------------------------------------------------------
877
+ Sidebar-owned account actions — opt-in
878
+ ----------------------------------------------------------------------------
879
+ `navConfig.actionsPlacement: 'sidebar'` moves the notification bell, the
880
+ profile link, the logout button and anything else a project mounts as a
881
+ "setting" out of the 50px content header and pins them to the bottom of the
882
+ rail, which was empty space. The page then gets the full height of the
883
+ glass. Nothing below applies unless the consumer publishes that key.
884
+ ------------------------------------------------------------------------- */
885
+ .aside--cms--rail {
886
+ /* Three bands: the logo plate, the nav list that takes what's left and
887
+ scrolls, and the actions pinned under it. */
888
+ display: flex;
889
+ flex-direction: column;
890
+
891
+ /* The rail clips itself so a long nav list can't spill; the panels that
892
+ open from the action rows — the notification list, the account
893
+ switcher — would be cut off by that. The nav list keeps its own
894
+ scroll container, so nothing else depended on this. */
895
+ overflow: visible;
896
+
897
+ .awrap {
898
+ /* `min-height: 0` or the nav list refuses to shrink below its
899
+ content and pushes the actions off the bottom of the rail. */
900
+ flex: 1 1 auto;
901
+ min-height: 0;
902
+ display: flex;
903
+ flex-direction: column;
904
+
905
+ .navwrap {
906
+ flex: 1 1 auto;
907
+ min-height: 0;
908
+ /* The fixed `calc(100vh - 50px)` assumed the rail's only other
909
+ band was the logo plate. The column measures it now. */
910
+ max-height: none;
911
+ }
912
+ }
913
+ }
914
+
915
+ .rail-actions {
916
+ flex: 0 0 auto;
917
+ position: relative;
918
+ /* Above the content header's sticky 995 so a panel opening from a row
919
+ is never painted underneath it. */
920
+ z-index: 996;
921
+ /* Reads as a footer to the list rather than one more entry in it. */
922
+ border-top: 1px solid rgba(var(--tertiary-color-rgb), 0.14);
923
+ padding: var(--padding) 0;
924
+
925
+ > ul {
926
+ list-style: none;
927
+ margin: 0;
928
+ padding: 0;
929
+ display: flex;
930
+ flex-direction: column;
931
+ width: 100%;
932
+
933
+ > li {
934
+ /* Same gutter as `.app-nav .nav-item`, so the rows line up with
935
+ the navigation above them to the pixel. */
936
+ width: 100%;
937
+ padding: 0.15rem 0.45rem;
938
+ box-sizing: border-box;
939
+ /* The positioning context for the panels a mounted component
940
+ opens — they anchor to the nearest positioned ancestor. */
941
+ position: relative;
942
+ }
943
+
944
+ // A row wears the rail's own language: the nav item's padding,
945
+ // radius and hover tint. Depth-limited on purpose — the panels these
946
+ // components open live deeper inside the same `li` and must keep
947
+ // their own styling.
948
+ > li > a,
949
+ > li > button,
950
+ > li > div > a,
951
+ > li > div > button {
952
+ width: 100%;
953
+ display: flex;
954
+ flex-wrap: nowrap;
955
+ align-items: center;
956
+ gap: 0.55rem;
957
+ box-sizing: border-box;
958
+ padding: 0.5rem 1rem;
959
+ margin: 0;
960
+ line-height: 1.25;
961
+ text-align: left;
962
+ text-decoration: none;
963
+ color: var(--tertiary-color);
964
+ background: var(--nav-bg);
965
+ border: none;
966
+ outline: none;
967
+ border-radius: var(--br);
968
+ font: inherit;
969
+ /* Stated after the `font` shorthand: the nav rows above take
970
+ these from the shared `.app-nav` rule, and a footer that sets
971
+ neither reads two sizes larger than the list it footers. */
972
+ font-size: var(--nav-item-font-size, var(--font-size-sm, 0.8125rem));
973
+ font-weight: var(--font-weight-medium, 500);
974
+ cursor: pointer;
975
+ appearance: none;
976
+ transition: all 0.65s cubic-bezier(0.25, 0.8, 0.25, 1);
977
+
978
+ svg {
979
+ flex: 0 0 auto;
980
+ color: var(--secondary-color);
981
+ }
982
+ }
983
+
984
+ > li > a:hover,
985
+ > li > button:hover,
986
+ > li > div > a:hover,
987
+ > li > div > button:hover,
988
+ > li > a.active {
989
+ background: rgba(var(--tertiary-color-rgb), 0.05);
990
+ color: var(--secondary-color);
991
+ }
992
+
993
+ // Components mounted here wrap their trigger in a div of their own
994
+ // (`.notificationContainer`, the switcher's wrapper). Left as an
995
+ // inline-ish block it would collapse the row.
996
+ > li > div {
997
+ width: 100%;
998
+ position: relative;
999
+ }
1000
+
1001
+ // A mounted component draws its own trigger and takes no label from
1002
+ // us, so the row publishes one as `--nav-action-label` and the
1003
+ // trigger wears it. Empty by default, so a component that already
1004
+ // labels itself is untouched. (The notification bell sets its own
1005
+ // fallback text — see Notification.module.scss.)
1006
+ > li > div > a::after {
1007
+ content: var(--nav-action-label, '');
1008
+ flex: 1 1 auto;
1009
+ min-width: 0;
1010
+ text-align: left;
1011
+ }
1012
+ }
1013
+ }
1014
+
873
1015
  .content {
874
1016
  margin-left: var(--sidebar-width);
875
1017
  flex-grow: 1;
@@ -952,6 +1094,21 @@
952
1094
  }
953
1095
  }
954
1096
 
1097
+ /* With the actions in the rail and no search field configured, the only
1098
+ thing left in the header is the drawer's hamburger — which exists below
1099
+ 1024px and nowhere else. The bar stays in the markup so the drawer keeps
1100
+ the 50px anchor it offsets itself by, and desktop simply drops it. */
1101
+ .content .content-header--mobile {
1102
+ display: none;
1103
+ }
1104
+
1105
+ /* Nothing sits above the page any more, so the main container is the
1106
+ viewport. Without this it is only as tall as its content and the page
1107
+ background stops short on a short screen. */
1108
+ .content--full {
1109
+ min-height: 100vh;
1110
+ }
1111
+
955
1112
  /* Rendered only while the mobile drawer is open; the phone block below
956
1113
  displays it. Desktop never opens the drawer, but the guard costs nothing. */
957
1114
  .drawer-backdrop {
@@ -1016,6 +1173,41 @@
1016
1173
  }
1017
1174
  }
1018
1175
 
1176
+ /* The account actions sit at the foot of the drawer, above the home
1177
+ indicator, at the same touch size as the nav rows above them. */
1178
+ .rail-actions {
1179
+ padding: 0.35rem 0 calc(0.35rem + env(safe-area-inset-bottom, 0px));
1180
+
1181
+ > ul > li {
1182
+ padding: 0.2rem 0.65rem;
1183
+ }
1184
+
1185
+ // Depth-limited: the panels these rows open live deeper in the same
1186
+ // `li` and size their own icons.
1187
+ > ul > li > a,
1188
+ > ul > li > button,
1189
+ > ul > li > div > a,
1190
+ > ul > li > div > button {
1191
+ padding: 0.85rem 1rem;
1192
+ font-size: 1rem;
1193
+ line-height: 1.3;
1194
+
1195
+ > svg {
1196
+ width: 20px;
1197
+ height: 20px;
1198
+ }
1199
+ }
1200
+ }
1201
+
1202
+ .content .content-header--mobile {
1203
+ display: flex;
1204
+ }
1205
+
1206
+ .content--full {
1207
+ /* The 50px bar is still there below this width. */
1208
+ min-height: calc(100vh - 50px);
1209
+ }
1210
+
1019
1211
  .drawer-backdrop {
1020
1212
  display: block;
1021
1213
  position: fixed;
@@ -448,3 +448,86 @@
448
448
  width: 300px;
449
449
  }
450
450
  }
451
+
452
+ /* ----------------------------------------------------------------------------
453
+ Mounted in the sidebar rail
454
+ ----------------------------------------------------------------------------
455
+ A project can opt its navigation into `actionsPlacement: 'sidebar'`, which
456
+ pins the account actions to the bottom of the rail instead of the content
457
+ header. The rail's block advertises itself with `data-nav-actions="sidebar"`
458
+ (see Navigation.jsx / Navigation.module.scss `.rail-actions`), which is all
459
+ this file needs to know.
460
+
461
+ Two things change. The trigger is a full-width row rather than a 36px chip,
462
+ so it carries a visible label — published by the row as `--nav-action-label`
463
+ because the component draws its own trigger and takes no label of its own.
464
+ And the panel anchors to the row's right edge: `top: 100%; right: 0` would
465
+ drop it below the foot of the viewport and off the left of the screen.
466
+
467
+ The row's own padding, colour and radius come from `.rail-actions`; only
468
+ what this file would otherwise force back is restated here.
469
+ ------------------------------------------------------------------------- */
470
+ [data-nav-actions='sidebar'] {
471
+ .notificationTrigger {
472
+ justify-content: flex-start;
473
+ /* The chip was a fixed 40px square; a row is as tall as the padding
474
+ the rail gives it, which is what the rows beside it measure. */
475
+ height: auto;
476
+
477
+ // The label. `content` reads the row's custom property; the fallback
478
+ // covers a settings entry that never carried a label — in the header
479
+ // the bell was an icon with a tooltip, so most don't.
480
+ &::after {
481
+ content: var(--nav-action-label, 'Notifications');
482
+ flex: 1 1 auto;
483
+ min-width: 0;
484
+ text-align: left;
485
+ }
486
+
487
+ // The chip's lift read as a stray button in a list of rows. The
488
+ // focus ring is left alone — it is the only keyboard affordance
489
+ // these rows have.
490
+ &:hover,
491
+ &:focus,
492
+ &:active {
493
+ transform: none;
494
+ }
495
+
496
+ &:hover {
497
+ background-color: rgba(var(--tertiary-color-rgb), 0.05);
498
+ color: var(--secondary-color);
499
+ }
500
+ }
501
+
502
+ // Was pinned to the corner of a round chip. On a wide row it belongs at
503
+ // the end of the line, where the eye lands after the label.
504
+ .badge {
505
+ top: 50%;
506
+ right: 14px;
507
+ transform: translateY(-50%);
508
+ }
509
+
510
+ // Beside the rail, bottom-aligned with the row that opened it.
511
+ .dropdownWrapper {
512
+ top: auto;
513
+ right: auto;
514
+ bottom: 0;
515
+ left: calc(100% + 10px);
516
+ margin-top: 0;
517
+ }
518
+
519
+ @media (max-width: 1024px) {
520
+ // Inside the drawer there is nothing to the right, so the panel
521
+ // opens upward from the row.
522
+ .dropdownWrapper {
523
+ left: 0;
524
+ bottom: calc(100% + 8px);
525
+ }
526
+
527
+ // Keeps the panel inside a phone's viewport when the list is long —
528
+ // it grows from the foot of the drawer towards the header.
529
+ .notiscrollwrap {
530
+ max-height: 40vh;
531
+ }
532
+ }
533
+ }
@@ -24,6 +24,28 @@
24
24
  z-index: 999;
25
25
  }
26
26
 
27
+ /* Mounted in the sidebar rail — `navConfig.actionsPlacement: 'sidebar'`.
28
+ The list is sized and shunted 470px left to hang off a chip at the top
29
+ right of the header; from a rail at the left edge that puts it off-screen.
30
+ The rail's action block advertises itself with `data-nav-actions`, which is
31
+ all this file needs to know. See Navigation.module.scss `.rail-actions`. */
32
+ [data-nav-actions='sidebar'] .notibox {
33
+ width: min(550px, calc(100vw - var(--sidebar-width) - 24px));
34
+ max-height: 60vh;
35
+ transform: none;
36
+ left: calc(100% + 10px);
37
+ bottom: 0;
38
+ }
39
+
40
+ @media (max-width: 1024px) {
41
+ /* Inside the drawer there is nothing to the right of the row. */
42
+ [data-nav-actions='sidebar'] .notibox {
43
+ width: min(550px, calc(100vw - 24px));
44
+ left: 0;
45
+ bottom: calc(100% + 8px);
46
+ }
47
+ }
48
+
27
49
  .notibox-item {
28
50
  width: 100%;
29
51
  display: block;