@visns-studio/visns-components 6.4.4 → 6.5.0

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.0",
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>
@@ -870,6 +870,140 @@
870
870
  }
871
871
  }
872
872
 
873
+ /* ----------------------------------------------------------------------------
874
+ Sidebar-owned account actions — opt-in
875
+ ----------------------------------------------------------------------------
876
+ `navConfig.actionsPlacement: 'sidebar'` moves the notification bell, the
877
+ profile link, the logout button and anything else a project mounts as a
878
+ "setting" out of the 50px content header and pins them to the bottom of the
879
+ rail, which was empty space. The page then gets the full height of the
880
+ glass. Nothing below applies unless the consumer publishes that key.
881
+ ------------------------------------------------------------------------- */
882
+ .aside--cms--rail {
883
+ /* Three bands: the logo plate, the nav list that takes what's left and
884
+ scrolls, and the actions pinned under it. */
885
+ display: flex;
886
+ flex-direction: column;
887
+
888
+ /* The rail clips itself so a long nav list can't spill; the panels that
889
+ open from the action rows — the notification list, the account
890
+ switcher — would be cut off by that. The nav list keeps its own
891
+ scroll container, so nothing else depended on this. */
892
+ overflow: visible;
893
+
894
+ .awrap {
895
+ /* `min-height: 0` or the nav list refuses to shrink below its
896
+ content and pushes the actions off the bottom of the rail. */
897
+ flex: 1 1 auto;
898
+ min-height: 0;
899
+ display: flex;
900
+ flex-direction: column;
901
+
902
+ .navwrap {
903
+ flex: 1 1 auto;
904
+ min-height: 0;
905
+ /* The fixed `calc(100vh - 50px)` assumed the rail's only other
906
+ band was the logo plate. The column measures it now. */
907
+ max-height: none;
908
+ }
909
+ }
910
+ }
911
+
912
+ .rail-actions {
913
+ flex: 0 0 auto;
914
+ position: relative;
915
+ /* Above the content header's sticky 995 so a panel opening from a row
916
+ is never painted underneath it. */
917
+ z-index: 996;
918
+ /* Reads as a footer to the list rather than one more entry in it. */
919
+ border-top: 1px solid rgba(var(--tertiary-color-rgb), 0.14);
920
+ padding: var(--padding) 0;
921
+
922
+ > ul {
923
+ list-style: none;
924
+ margin: 0;
925
+ padding: 0;
926
+ display: flex;
927
+ flex-direction: column;
928
+ width: 100%;
929
+
930
+ > li {
931
+ /* Same gutter as `.app-nav .nav-item`, so the rows line up with
932
+ the navigation above them to the pixel. */
933
+ width: 100%;
934
+ padding: 0.15rem 0.45rem;
935
+ box-sizing: border-box;
936
+ /* The positioning context for the panels a mounted component
937
+ opens — they anchor to the nearest positioned ancestor. */
938
+ position: relative;
939
+ }
940
+
941
+ // A row wears the rail's own language: the nav item's padding,
942
+ // radius and hover tint. Depth-limited on purpose — the panels these
943
+ // components open live deeper inside the same `li` and must keep
944
+ // their own styling.
945
+ > li > a,
946
+ > li > button,
947
+ > li > div > a,
948
+ > li > div > button {
949
+ width: 100%;
950
+ display: flex;
951
+ flex-wrap: nowrap;
952
+ align-items: center;
953
+ gap: 0.55rem;
954
+ box-sizing: border-box;
955
+ padding: 0.5rem 1rem;
956
+ margin: 0;
957
+ line-height: 1.25;
958
+ text-align: left;
959
+ text-decoration: none;
960
+ color: var(--tertiary-color);
961
+ background: var(--nav-bg);
962
+ border: none;
963
+ outline: none;
964
+ border-radius: var(--br);
965
+ font: inherit;
966
+ cursor: pointer;
967
+ appearance: none;
968
+ transition: all 0.65s cubic-bezier(0.25, 0.8, 0.25, 1);
969
+
970
+ svg {
971
+ flex: 0 0 auto;
972
+ color: var(--secondary-color);
973
+ }
974
+ }
975
+
976
+ > li > a:hover,
977
+ > li > button:hover,
978
+ > li > div > a:hover,
979
+ > li > div > button:hover,
980
+ > li > a.active {
981
+ background: rgba(var(--tertiary-color-rgb), 0.05);
982
+ color: var(--secondary-color);
983
+ }
984
+
985
+ // Components mounted here wrap their trigger in a div of their own
986
+ // (`.notificationContainer`, the switcher's wrapper). Left as an
987
+ // inline-ish block it would collapse the row.
988
+ > li > div {
989
+ width: 100%;
990
+ position: relative;
991
+ }
992
+
993
+ // A mounted component draws its own trigger and takes no label from
994
+ // us, so the row publishes one as `--nav-action-label` and the
995
+ // trigger wears it. Empty by default, so a component that already
996
+ // labels itself is untouched. (The notification bell sets its own
997
+ // fallback text — see Notification.module.scss.)
998
+ > li > div > a::after {
999
+ content: var(--nav-action-label, '');
1000
+ flex: 1 1 auto;
1001
+ min-width: 0;
1002
+ text-align: left;
1003
+ }
1004
+ }
1005
+ }
1006
+
873
1007
  .content {
874
1008
  margin-left: var(--sidebar-width);
875
1009
  flex-grow: 1;
@@ -952,6 +1086,21 @@
952
1086
  }
953
1087
  }
954
1088
 
1089
+ /* With the actions in the rail and no search field configured, the only
1090
+ thing left in the header is the drawer's hamburger — which exists below
1091
+ 1024px and nowhere else. The bar stays in the markup so the drawer keeps
1092
+ the 50px anchor it offsets itself by, and desktop simply drops it. */
1093
+ .content .content-header--mobile {
1094
+ display: none;
1095
+ }
1096
+
1097
+ /* Nothing sits above the page any more, so the main container is the
1098
+ viewport. Without this it is only as tall as its content and the page
1099
+ background stops short on a short screen. */
1100
+ .content--full {
1101
+ min-height: 100vh;
1102
+ }
1103
+
955
1104
  /* Rendered only while the mobile drawer is open; the phone block below
956
1105
  displays it. Desktop never opens the drawer, but the guard costs nothing. */
957
1106
  .drawer-backdrop {
@@ -1016,6 +1165,41 @@
1016
1165
  }
1017
1166
  }
1018
1167
 
1168
+ /* The account actions sit at the foot of the drawer, above the home
1169
+ indicator, at the same touch size as the nav rows above them. */
1170
+ .rail-actions {
1171
+ padding: 0.35rem 0 calc(0.35rem + env(safe-area-inset-bottom, 0px));
1172
+
1173
+ > ul > li {
1174
+ padding: 0.2rem 0.65rem;
1175
+ }
1176
+
1177
+ // Depth-limited: the panels these rows open live deeper in the same
1178
+ // `li` and size their own icons.
1179
+ > ul > li > a,
1180
+ > ul > li > button,
1181
+ > ul > li > div > a,
1182
+ > ul > li > div > button {
1183
+ padding: 0.85rem 1rem;
1184
+ font-size: 1rem;
1185
+ line-height: 1.3;
1186
+
1187
+ > svg {
1188
+ width: 20px;
1189
+ height: 20px;
1190
+ }
1191
+ }
1192
+ }
1193
+
1194
+ .content .content-header--mobile {
1195
+ display: flex;
1196
+ }
1197
+
1198
+ .content--full {
1199
+ /* The 50px bar is still there below this width. */
1200
+ min-height: calc(100vh - 50px);
1201
+ }
1202
+
1019
1203
  .drawer-backdrop {
1020
1204
  display: block;
1021
1205
  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;