@visns-studio/visns-components 6.24.4 → 6.26.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.
Files changed (51) hide show
  1. package/package.json +4 -2
  2. package/src/components/Autocomplete.jsx +189 -119
  3. package/src/components/DataGrid.jsx +472 -31
  4. package/src/components/Navigation.jsx +475 -51
  5. package/src/components/auth/ClientAuthFrame.jsx +5 -0
  6. package/src/components/auth/ClientAuthScreen.jsx +29 -0
  7. package/src/components/callQueue/CallQueueDiagnostics.jsx +1043 -0
  8. package/src/components/callQueue/CallQueuePop.jsx +713 -90
  9. package/src/components/callQueue/CallQueueSettings.jsx +308 -146
  10. package/src/components/callQueue/callPopStatus.js +236 -0
  11. package/src/components/callQueue/callQueueHelpers.js +284 -2
  12. package/src/components/columns/ColumnRenderers.jsx +3 -46
  13. package/src/components/columns/StackedRow.jsx +186 -0
  14. package/src/components/controls/DataGridSearch.jsx +110 -2
  15. package/src/components/controls/DataGridSortSheet.jsx +155 -0
  16. package/src/components/generic/GenericAuth.jsx +50 -18
  17. package/src/components/generic/GenericDashboard.jsx +20 -1
  18. package/src/components/generic/GenericDetail.jsx +446 -259
  19. package/src/components/mapboxSearchBox.js +640 -0
  20. package/src/components/navBadges.js +63 -1
  21. package/src/components/navDrawer.js +147 -0
  22. package/src/components/sms/SmsThreadPanel.jsx +34 -6
  23. package/src/components/sms/smsHelpers.js +15 -0
  24. package/src/components/styles/CallQueueDiagnostics.module.scss +398 -0
  25. package/src/components/styles/CallQueuePop.module.scss +29 -0
  26. package/src/components/styles/CallQueueSettings.module.scss +93 -0
  27. package/src/components/styles/ClientAuth.module.scss +39 -0
  28. package/src/components/styles/DataGrid.module.scss +158 -5
  29. package/src/components/styles/Field.module.scss +52 -1
  30. package/src/components/styles/Form.module.scss +82 -0
  31. package/src/components/styles/GenericClientPortal.module.scss +72 -20
  32. package/src/components/styles/GenericDashboard.module.scss +50 -0
  33. package/src/components/styles/GenericDetail.module.scss +63 -1
  34. package/src/components/styles/GenericDynamic.module.scss +23 -0
  35. package/src/components/styles/GenericFormBuilder.module.scss +11 -0
  36. package/src/components/styles/GenericIndex.module.scss +6 -1
  37. package/src/components/styles/Navigation.module.scss +460 -7
  38. package/src/components/styles/Sms.module.scss +92 -0
  39. package/src/components/styles/StackedRow.module.scss +182 -0
  40. package/src/components/styles/TicketConversation.module.scss +76 -0
  41. package/src/components/styles/Vault.module.scss +192 -0
  42. package/src/components/styles/density.css +10 -0
  43. package/src/components/styles/global-datagrid.css +163 -0
  44. package/src/components/styles/global.css +20 -0
  45. package/src/components/tickets/TicketConversation.jsx +13 -8
  46. package/src/components/utils/ConfirmDialog.js +22 -3
  47. package/src/components/utils/cardLayout.js +666 -0
  48. package/src/components/utils/contactChannels.js +130 -0
  49. package/src/components/utils/editPlacement.js +95 -0
  50. package/src/components/utils/useDensity.js +303 -7
  51. package/src/index.js +42 -0
@@ -33,9 +33,99 @@ import Notification from './Notification';
33
33
  import SwitchAccount from './SwitchAccount';
34
34
  import BusinessCardOcr from './BusinessCardOcr';
35
35
  import { isNavActive, bestMatchingUrl, cleanUrl } from './navActive';
36
- import { navBadge, badgeFor, badgeAriaLabel } from './navBadges';
36
+ import {
37
+ navBadge,
38
+ badgeFor,
39
+ badgeAriaLabel,
40
+ DEFAULT_BADGE_TONE,
41
+ } from './navBadges';
42
+ import { drawerRows } from './navDrawer';
37
43
  import styles from './styles/Navigation.module.scss';
38
44
 
45
+ /**
46
+ * Where the header layout stops being a bar and becomes a drawer.
47
+ *
48
+ * The same 1024px the sidebar layouts have always used (see the media block
49
+ * in Navigation.module.scss), stated once here because the JS and the
50
+ * stylesheet now have to agree: below it the nav strip is hidden by CSS and
51
+ * the drawer is mounted by JS, and a disagreement is a screen with no
52
+ * navigation on it at all.
53
+ */
54
+ const DRAWER_BREAKPOINT = '(max-width: 1024px)';
55
+
56
+ /**
57
+ * A pointer that cannot hover. iPads in landscape are 1024px wide — above the
58
+ * drawer breakpoint — so the DESKTOP bar is what a large tablet gets, and its
59
+ * group dropdowns opened on `:hover` alone. This is the query that tells the
60
+ * bar to drive them from state instead. See the group `<button>` below.
61
+ */
62
+ const COARSE_POINTER = '(hover: none)';
63
+
64
+ /**
65
+ * The account actions that stay in the bar once the drawer exists, by
66
+ * settings id. Everything else moves into the drawer.
67
+ *
68
+ * THREE, AND WHICH THREE IS THE DECISION: search, the notification bell and
69
+ * the ticket-queue count. Two of them carry a number that is the reason to
70
+ * look at the bar at all — a badge inside a closed drawer counts nothing —
71
+ * and search is the fastest route to any record in the CRM, which is what a
72
+ * one-handed reader on a phone is almost always after. The rest (messages,
73
+ * calendar, vault, settings, profile, sign out) are places you go, not
74
+ * numbers you watch, and they read better as full-width labelled rows than as
75
+ * unlabelled 34px circles crushed against the edge of a 390px screen.
76
+ *
77
+ * An id that is not in this list and not in the drawer would simply vanish,
78
+ * so the two are derived from ONE array: the bar renders the ids in it, the
79
+ * drawer renders the rest. A project whose ids differ from these keeps every
80
+ * action — they all fall through to the drawer.
81
+ *
82
+ * It is also what stops a component being mounted twice. `renderers` entries
83
+ * such as a universal-search palette or an SMS badge register global
84
+ * keystrokes and open websockets; a bar copy hidden with `display: none`
85
+ * beside a live drawer copy would run both.
86
+ */
87
+ const MOBILE_BAR_ACTIONS = ['search', 'notifications', 'ticketQueue'];
88
+
89
+ /**
90
+ * A media query as React state.
91
+ *
92
+ * Read SYNCHRONOUSLY in the initialiser, not in an effect: the first paint
93
+ * decides whether the bar draws nine chips or three, and a render at the
94
+ * wrong width followed by a correction is a visible flash of the desktop bar
95
+ * on every page load on a phone. The effect only keeps it honest afterwards —
96
+ * on rotation, on a resized window, on a desktop browser's device toolbar.
97
+ *
98
+ * Guarded for a window-less render even though nothing here runs on a server
99
+ * today; the guard costs one comparison and the alternative is a crash.
100
+ */
101
+ const useMediaQuery = (query) => {
102
+ const [matches, setMatches] = useState(() =>
103
+ typeof window !== 'undefined' && typeof window.matchMedia === 'function'
104
+ ? window.matchMedia(query).matches
105
+ : false
106
+ );
107
+
108
+ useEffect(() => {
109
+ if (
110
+ typeof window === 'undefined' ||
111
+ typeof window.matchMedia !== 'function'
112
+ ) {
113
+ return undefined;
114
+ }
115
+
116
+ const mql = window.matchMedia(query);
117
+ const onChange = (event) => setMatches(event.matches);
118
+
119
+ // The width may have changed between the initialiser and this effect.
120
+ setMatches(mql.matches);
121
+ mql.addEventListener('change', onChange);
122
+
123
+ return () => mql.removeEventListener('change', onChange);
124
+ }, [query]);
125
+
126
+ return matches;
127
+ };
128
+
39
129
  // Opt-in icons for the sidebar (simple/cms) layouts: a nav entry names one
40
130
  // via its `icon` key, falling back to its `id`. Unknown names render
41
131
  // nothing, and the header layout never renders these, so existing nav
@@ -133,10 +223,25 @@ const actionLabel = (n) => {
133
223
  *
134
224
  * Renders nothing without a badge, so every caller can hand it whatever
135
225
  * navBadge() returned without guarding first.
226
+ *
227
+ * The tone becomes a modifier class and only when it is not the default: `ok`
228
+ * IS the base rule, so a `.nav-badge-ok` would be an empty block that every
229
+ * pill in the bar carried for nothing. navBadges.js has already reduced an
230
+ * unrecognised tone to the default, so the lookup below cannot miss.
136
231
  */
137
232
  const BadgePill = ({ badge }) =>
138
233
  badge ? (
139
- <span className={styles['nav-badge']} aria-hidden="true">
234
+ <span
235
+ className={[
236
+ styles['nav-badge'],
237
+ badge.tone && badge.tone !== DEFAULT_BADGE_TONE
238
+ ? styles[`nav-badge-${badge.tone}`]
239
+ : '',
240
+ ]
241
+ .filter(Boolean)
242
+ .join(' ')}
243
+ aria-hidden="true"
244
+ >
140
245
  {badge.display}
141
246
  </span>
142
247
  ) : null;
@@ -325,9 +430,11 @@ function Navigation({
325
430
  renderers = {},
326
431
  /*
327
432
  * Live counts for navigation items: `{ [navId]: number }`, or the object
328
- * form `{ [navId]: { count, label } }` when the accessible name needs a
329
- * noun other than "open". See navBadges.js, which owns every rule about
330
- * what renders and what does not.
433
+ * form `{ [navId]: { count, label, tone } }` when the accessible name
434
+ * needs a noun other than "open", or the number needs a severity
435
+ * `tone: 'ok' | 'warn' | 'danger'`, default 'ok', which is the green pill
436
+ * everything wore before tones existed. See navBadges.js, which owns every
437
+ * rule about what renders and what does not.
331
438
  *
332
439
  * Empty by default and additive: an id with no entry, a zero, or a value
333
440
  * that will not parse draws nothing at all, so a consumer that publishes
@@ -349,6 +456,13 @@ function Navigation({
349
456
  const currentPage = location.pathname.substr(1) || 'dashboard';
350
457
  const [isOpen, setOpen] = useState(false);
351
458
 
459
+ // Below 1024px BOTH layouts are a drawer: the sidebar layouts slide their
460
+ // rail in, and the header layout — which has no rail — mounts one. Read as
461
+ // state rather than left to CSS because the two surfaces must not both
462
+ // render the same account action (see MOBILE_BAR_ACTIONS).
463
+ const isNarrow = useMediaQuery(DRAWER_BREAKPOINT);
464
+ const isCoarsePointer = useMediaQuery(COARSE_POINTER);
465
+
352
466
  // The mobile drawer is a full-viewport fixed overlay; without a scroll
353
467
  // lock the page behind it keeps scrolling under the user's thumb.
354
468
  useEffect(() => {
@@ -361,6 +475,32 @@ function Navigation({
361
475
  }
362
476
  return undefined;
363
477
  }, [isOpen]);
478
+
479
+ // Widening past the breakpoint takes the drawer away — including, in the
480
+ // header layout, the element it was rendered into. Left open, the state
481
+ // would be true with nothing on screen holding it, and the scroll lock
482
+ // above would keep the page frozen with no way to release it.
483
+ useEffect(() => {
484
+ if (!isNarrow) {
485
+ setOpen(false);
486
+ }
487
+ }, [isNarrow]);
488
+
489
+ /*
490
+ * A page change closes the drawer, whoever asked for it.
491
+ *
492
+ * Every link IN the drawer already closes it on the way out, and that is
493
+ * not enough: the account actions at the foot of it are mounted
494
+ * components — an SMS inbox, a credential palette — that open a panel of
495
+ * their own and then navigate from inside it, with nothing passing
496
+ * through those handlers. The drawer stayed sitting over the page they
497
+ * had just taken you to. Keyed on the PATH only, so a screen that
498
+ * rewrites its own query string cannot slam the menu shut mid-scroll.
499
+ */
500
+ useEffect(() => {
501
+ setOpen(false);
502
+ }, [currentPage]);
503
+
364
504
  const [isScrolled, setIsScrolled] = useState(false);
365
505
  const [navData, setNavData] = useState(navConfig);
366
506
  const [showBusinessCardOcr, setShowBusinessCardOcr] = useState(false);
@@ -390,6 +530,18 @@ function Navigation({
390
530
  );
391
531
  };
392
532
 
533
+ /**
534
+ * One account action as a header chip.
535
+ *
536
+ * Every branch's `<li>` carries `data-setting={n.id}`. Nothing in this
537
+ * library styles off it — WHICH chips survive the drawer breakpoint is a
538
+ * JS decision (MOBILE_BAR_ACTIONS), because a chip hidden in CSS is still
539
+ * a mounted component holding a websocket and a global keystroke. It is
540
+ * here so a consuming app can reach a chip it mounted by name from its own
541
+ * stylesheet, and so the cluster is legible in the inspector: nine
542
+ * identical `<li><a>` with an icon inside told you nothing about which was
543
+ * which.
544
+ */
393
545
  const renderSetting = (n) => {
394
546
  const iconComponents = SETTING_ICONS;
395
547
 
@@ -403,7 +555,7 @@ function Navigation({
403
555
  // A consumer-supplied renderer wins over the built-in chain.
404
556
  if (typeof renderers[n.id] === 'function') {
405
557
  return (
406
- <li>
558
+ <li data-setting={n.id}>
407
559
  {renderers[n.id]({
408
560
  setting: n,
409
561
  userProfile,
@@ -415,13 +567,13 @@ function Navigation({
415
567
 
416
568
  if (n.id === 'notifications') {
417
569
  return (
418
- <li>
570
+ <li data-setting={n.id}>
419
571
  <Notification setSystemAuth={setSystemAuth} />
420
572
  </li>
421
573
  );
422
574
  } else if (n.id === 'businessCardOcr') {
423
575
  return (
424
- <li>
576
+ <li data-setting={n.id}>
425
577
  <button
426
578
  type="button"
427
579
  data-tooltip-id="system-tooltip"
@@ -437,7 +589,7 @@ function Navigation({
437
589
  );
438
590
  } else if (n.id === 'switchAccount') {
439
591
  return (
440
- <li>
592
+ <li data-setting={n.id}>
441
593
  <SwitchAccount
442
594
  setSystemAuth={setSystemAuth}
443
595
  setting={n}
@@ -449,7 +601,7 @@ function Navigation({
449
601
  const IconComponent = iconComponents[n.icon];
450
602
  const label = actionLabel(n);
451
603
  return (
452
- <li>
604
+ <li data-setting={n.id}>
453
605
  <button
454
606
  type="button"
455
607
  data-tooltip-id="system-tooltip"
@@ -475,7 +627,7 @@ function Navigation({
475
627
  const settingBadge = badgeFor(badges, n.id);
476
628
 
477
629
  return (
478
- <li>
630
+ <li data-setting={n.id}>
479
631
  <Link
480
632
  to={n.url}
481
633
  data-tooltip-id="system-tooltip"
@@ -602,9 +754,59 @@ function Navigation({
602
754
  );
603
755
  };
604
756
 
605
- const [, setActiveDropdown] = useState(null); // We only need the setter
757
+ /**
758
+ * Which group's dropdown a TOUCH reader has opened.
759
+ *
760
+ * Null on a mouse, always: hover and `:focus-within` open the desktop menu
761
+ * in CSS and this state is not consulted (see the `aria-expanded` note on
762
+ * the group button). It exists because a coarse pointer has neither — the
763
+ * old code even blurred the button on click, so on a touch screen the menu
764
+ * opened for exactly as long as the tap lasted.
765
+ *
766
+ * This replaces an `activeDropdown` state whose value was discarded at the
767
+ * point of declaration (`const [, setActiveDropdown]`): it re-rendered the
768
+ * whole bar on every hover to change nothing, because the only thing it
769
+ * did was toggle the scroll container's overflow, which is a DOM poke.
770
+ * That poke is `liftNavClipping` now, and it is called from the tap path
771
+ * as well — the dropdown was clipped by the strip's `overflow-x: auto`
772
+ * everywhere the pointer had not been.
773
+ */
774
+ const [openGroup, setOpenGroup] = useState(null);
606
775
  const navWrapRef = useRef(null);
607
776
 
777
+ /**
778
+ * Let an open dropdown out of the horizontally-scrolling nav strip.
779
+ *
780
+ * The strip is `overflow-x: auto`, and a scroll container clips on BOTH
781
+ * axes however `visible` the other one is written — so a menu hanging
782
+ * below the bar is cut off at the bar's own bottom edge.
783
+ */
784
+ const liftNavClipping = (lift) => {
785
+ if (navWrapRef.current) {
786
+ navWrapRef.current.style.overflowX = lift ? 'visible' : 'auto';
787
+ }
788
+ };
789
+
790
+ // A tap anywhere else closes the group a touch reader opened. Without it
791
+ // the only way to dismiss the menu is to tap the heading again, which is
792
+ // not how any other menu on the device behaves. Mouse users never get
793
+ // here: `openGroup` is only ever set on a coarse pointer.
794
+ useEffect(() => {
795
+ if (openGroup === null) {
796
+ return undefined;
797
+ }
798
+
799
+ const onDown = (event) => {
800
+ if (!navWrapRef.current?.contains(event.target)) {
801
+ setOpenGroup(null);
802
+ liftNavClipping(false);
803
+ }
804
+ };
805
+
806
+ document.addEventListener('pointerdown', onDown);
807
+ return () => document.removeEventListener('pointerdown', onDown);
808
+ }, [openGroup]);
809
+
608
810
  const renderNav = (n) => {
609
811
  let navItemClasses;
610
812
  const NavItemIcon =
@@ -628,21 +830,13 @@ function Navigation({
628
830
 
629
831
  const handleMouseEnter = () => {
630
832
  if (hasChildren) {
631
- setActiveDropdown(n.id);
632
- // Temporarily disable overflow on the navwrap when dropdown is active
633
- if (navWrapRef.current) {
634
- navWrapRef.current.style.overflowX = 'visible';
635
- }
833
+ liftNavClipping(true);
636
834
  }
637
835
  };
638
836
 
639
837
  const handleMouseLeave = () => {
640
838
  if (hasChildren) {
641
- setActiveDropdown(null);
642
- // Restore overflow when dropdown is no longer active
643
- if (navWrapRef.current) {
644
- navWrapRef.current.style.overflowX = 'auto';
645
- }
839
+ liftNavClipping(false);
646
840
  }
647
841
  };
648
842
 
@@ -679,7 +873,15 @@ function Navigation({
679
873
  ? styles.active
680
874
  : ''
681
875
  }
682
- onClick={() => setOpen(false)}
876
+ /* Closes both menus this row can sit
877
+ inside: the drawer, and — on a
878
+ touch screen — the dropdown the tap
879
+ on the heading opened. */
880
+ onClick={() => {
881
+ setOpen(false);
882
+ setOpenGroup(null);
883
+ liftNavClipping(false);
884
+ }}
683
885
  >
684
886
  {child.label}
685
887
  <BadgePill badge={childBadge} />
@@ -735,25 +937,49 @@ function Navigation({
735
937
  * screen. A button takes focus, and the stylesheet opens
736
938
  * the dropdown on :focus-within as well as :hover.
737
939
  *
738
- * No onClick and no aria-expanded: the open state lives
739
- * in CSS, not in React (deliberately see the discarded
740
- * `activeDropdown` setter above, which exists only to
741
- * toggle the scroll container's overflow), and claiming a
742
- * state this component does not hold would be a lie to
940
+ * ON A FINE POINTER the open state still lives in CSS, not
941
+ * in React, and `aria-expanded` is therefore absent: the
942
+ * menu opens on hover, this component is never told, and
943
+ * publishing a state it does not hold would be a lie to
743
944
  * the screen reader rather than a help.
945
+ *
946
+ * ON A COARSE POINTER there is no hover to open it with,
947
+ * so the tap does — and then React DOES hold the state,
948
+ * `aria-expanded` is real, and the stylesheet opens the
949
+ * menu off that attribute. Same markup, two mechanisms,
950
+ * each honest about the input it is serving.
744
951
  */
745
952
  <button
746
953
  type="button"
747
954
  aria-haspopup="true"
955
+ aria-expanded={
956
+ isCoarsePointer ? openGroup === n.id : undefined
957
+ }
748
958
  aria-label={ariaLabel}
749
- /* The menu opens on :hover OR :focus-within. A MOUSE
750
- click leaves the button focused, so the menu it
751
- opened stayed pinned while hovering the next group
752
- opened that one too — two dropdowns at once. Blur
753
- on pointer clicks only: e.detail is the click
754
- count, 0 for keyboard "clicks", so Tab + Enter
755
- users keep the focus that holds their menu open. */
959
+ /* Fine pointer: the menu opens on :hover OR
960
+ :focus-within. A MOUSE click leaves the button
961
+ focused, so the menu it opened stayed pinned while
962
+ hovering the next group opened that one too — two
963
+ dropdowns at once. Blur on pointer clicks only:
964
+ e.detail is the click count, 0 for keyboard
965
+ "clicks", so Tab + Enter users keep the focus that
966
+ holds their menu open.
967
+
968
+ Coarse pointer: NEVER blur — the blur is precisely
969
+ what made the menu impossible to open by tapping,
970
+ since the tap that opened it removed the focus
971
+ holding it open in the same gesture. Toggle instead,
972
+ and lift the strip's clipping the way a hover does,
973
+ or the menu opens inside a scroll container that
974
+ cuts it off at the bar's bottom edge. */
756
975
  onClick={(e) => {
976
+ if (isCoarsePointer) {
977
+ const next = openGroup === n.id ? null : n.id;
978
+ setOpenGroup(next);
979
+ liftNavClipping(next !== null);
980
+ return;
981
+ }
982
+
757
983
  if (e.detail > 0) {
758
984
  e.currentTarget.blur();
759
985
  }
@@ -1071,6 +1297,169 @@ function Navigation({
1071
1297
  </div>
1072
1298
  );
1073
1299
 
1300
+ /* ------------------------------------------------------------------ *
1301
+ * The header layout's drawer (≤1024px)
1302
+ * ------------------------------------------------------------------ *
1303
+ * Everything below this comment is for the `full` layout — the one-line
1304
+ * bar — and exists because it had NO mobile story at all: the hamburger,
1305
+ * the sliding panel, the backdrop and the body scroll lock were all
1306
+ * written for the sidebar layouts, and the bar's answer to a 390px screen
1307
+ * was a horizontally-scrolling strip with the scrollbar hidden. Nine
1308
+ * account chips at `width: max-content` then set the bar's minimum width
1309
+ * at roughly 530px, which is wider than the phone: the navigation column
1310
+ * collapsed to nothing and the whole page panned sideways.
1311
+ *
1312
+ * So the drawer machinery is REUSED rather than rebuilt — the same
1313
+ * `aside--cms` panel, the same `aside--cms--open` transform, the same
1314
+ * `drawer-backdrop`, the same `rail-actions` footer and the same
1315
+ * `isOpen` state and scroll-lock effect that were already in scope. What
1316
+ * is new is the flat row list (groups have no dropdown to open here; see
1317
+ * navDrawer.js) and the anchor: this drawer hangs off the bottom of the
1318
+ * header bar rather than the 50px content header, which is what
1319
+ * `--nav-header-height` in the stylesheet measures.
1320
+ */
1321
+ const headerLayout = layout !== 'simple' && layout !== 'cms';
1322
+ const hasDrawer = headerLayout && isNarrow;
1323
+
1324
+ /*
1325
+ * The account actions, split in two.
1326
+ *
1327
+ * Above the breakpoint there is no split: `barActions` is every action
1328
+ * and `drawerActions` is empty, so the desktop bar renders exactly what
1329
+ * it always rendered. Below it, an action appears in ONE of the two
1330
+ * surfaces and never both — see MOBILE_BAR_ACTIONS for why that matters
1331
+ * more than it looks like it should.
1332
+ */
1333
+ const visibleSettings = navData.settings.filter(
1334
+ (setting) => setting.hidden !== true && setting.permission === true
1335
+ );
1336
+ const barActions = hasDrawer
1337
+ ? visibleSettings.filter((setting) =>
1338
+ MOBILE_BAR_ACTIONS.includes(setting.id)
1339
+ )
1340
+ : visibleSettings;
1341
+ const drawerActions = hasDrawer
1342
+ ? visibleSettings.filter(
1343
+ (setting) => !MOBILE_BAR_ACTIONS.includes(setting.id)
1344
+ )
1345
+ : [];
1346
+
1347
+ const headerDrawer = !hasDrawer ? null : (
1348
+ <>
1349
+ <aside
1350
+ id="nav-drawer"
1351
+ aria-label="Main menu"
1352
+ className={[
1353
+ styles['aside--cms'],
1354
+ styles['aside--drawer'],
1355
+ isOpen ? styles['aside--cms--open'] : '',
1356
+ ]
1357
+ .filter(Boolean)
1358
+ .join(' ')}
1359
+ >
1360
+ <nav className={styles['drawer-nav']}>
1361
+ <ul>
1362
+ {drawerRows(navData.navigations, currentPage).map(
1363
+ (row, rowKey) => {
1364
+ /*
1365
+ * `badgeFor`, not `navBadge`: the group's own
1366
+ * count, never a roll-up of its children's.
1367
+ * The bar rolls a group up because its
1368
+ * children are hidden inside a closed menu;
1369
+ * here they are rows of their own, two
1370
+ * fingers below, each wearing its own pill —
1371
+ * so a roll-up would show the same work
1372
+ * twice and the totals would not add up.
1373
+ */
1374
+ const rowBadge = badgeFor(badges, row.id);
1375
+
1376
+ return (
1377
+ <li
1378
+ key={`drawer-row-${rowKey}`}
1379
+ className={
1380
+ row.depth > 0
1381
+ ? styles['drawer-child']
1382
+ : undefined
1383
+ }
1384
+ >
1385
+ {row.kind === 'heading' ? (
1386
+ /* Inert on purpose. A group
1387
+ parent has no page, so there is
1388
+ nothing to navigate to and
1389
+ nothing to disclose — the rows
1390
+ it names are already open
1391
+ underneath it. */
1392
+ <span
1393
+ className={
1394
+ styles['drawer-heading']
1395
+ }
1396
+ >
1397
+ {row.label}
1398
+ </span>
1399
+ ) : (
1400
+ <Link
1401
+ to={row.url}
1402
+ target={
1403
+ row.target
1404
+ ? row.target
1405
+ : '_self'
1406
+ }
1407
+ className={
1408
+ row.active
1409
+ ? styles.active
1410
+ : ''
1411
+ }
1412
+ aria-label={badgeAriaLabel(
1413
+ row.label,
1414
+ rowBadge
1415
+ )}
1416
+ onClick={() => setOpen(false)}
1417
+ >
1418
+ <span>{row.label}</span>
1419
+ <BadgePill badge={rowBadge} />
1420
+ </Link>
1421
+ )}
1422
+ </li>
1423
+ );
1424
+ }
1425
+ )}
1426
+ </ul>
1427
+ </nav>
1428
+
1429
+ {/* The same footer the sidebar rail uses, carrying the
1430
+ actions the bar gave up — `renderRailSetting` already
1431
+ knows how to draw every one of them as a labelled row,
1432
+ mounted components included, and already closes the
1433
+ drawer when one is tapped. `data-nav-actions` is what
1434
+ tells those components' own stylesheets to open their
1435
+ panels away from a header chip they are no longer in. */}
1436
+ <div
1437
+ className={styles['rail-actions']}
1438
+ data-nav-actions="sidebar"
1439
+ >
1440
+ <ul>
1441
+ {drawerActions.map((setting, settingKey) => (
1442
+ <React.Fragment
1443
+ key={`drawer-setting-${settingKey}`}
1444
+ >
1445
+ {renderRailSetting(setting)}
1446
+ </React.Fragment>
1447
+ ))}
1448
+ </ul>
1449
+ </div>
1450
+ </aside>
1451
+
1452
+ {isOpen ? (
1453
+ <button
1454
+ type="button"
1455
+ className={styles['drawer-backdrop']}
1456
+ aria-label="Close menu"
1457
+ onClick={() => setOpen(false)}
1458
+ />
1459
+ ) : null}
1460
+ </>
1461
+ );
1462
+
1074
1463
  return layout === 'simple' || layout === 'cms' ? (
1075
1464
  <div className={styles.cwrap}>
1076
1465
  <aside
@@ -1199,6 +1588,35 @@ function Navigation({
1199
1588
  </div>
1200
1589
  ) : (
1201
1590
  <div className={styles.hwrap}>
1591
+ {/* First in the source, and therefore first in the tab order and
1592
+ first in the bar's grid — the thumb reaches the left edge of a
1593
+ phone and the menu is the thing it is reaching for. Rendered
1594
+ only below the breakpoint: above it the strip beside the logo
1595
+ IS the menu, and a hamburger next to a visible menu is two
1596
+ doors into one room. */}
1597
+ {hasDrawer ? (
1598
+ <button
1599
+ type="button"
1600
+ className={[
1601
+ styles.mobmenu,
1602
+ styles.hmenu,
1603
+ isOpen ? styles['mobmenu--open'] : '',
1604
+ ]
1605
+ .filter(Boolean)
1606
+ .join(' ')}
1607
+ aria-label={isOpen ? 'Close menu' : 'Open menu'}
1608
+ aria-expanded={isOpen}
1609
+ aria-controls="nav-drawer"
1610
+ onClick={() => setOpen(!isOpen)}
1611
+ >
1612
+ <span></span>
1613
+ <span></span>
1614
+ <span></span>
1615
+ <span></span>
1616
+ <span></span>
1617
+ <span></span>
1618
+ </button>
1619
+ ) : null}
1202
1620
  <div className={styles.logo}>
1203
1621
  <Link to="/">
1204
1622
  <img src={logo} alt="System Logo" />
@@ -1217,25 +1635,31 @@ function Navigation({
1217
1635
  )}
1218
1636
  </ul>
1219
1637
  </div>
1220
- <div
1221
- className={`${
1222
- themeType === 'alternate'
1223
- ? styles['hactions-alternate']
1224
- : styles.hactions
1225
- } ${debugIcons ? styles['debug-icons'] : ''}`}
1226
- ref={actionItemsRef}
1227
- >
1228
- <ul>
1229
- {navData.settings.map((setting, settingKey) =>
1230
- setting.hidden !== true &&
1231
- setting.permission === true ? (
1638
+ {/* The cluster draws a pill-shaped tray behind its chips, so an
1639
+ empty one is a small blank capsule floating in the bar. It can
1640
+ only happen below the breakpoint, in a project whose settings
1641
+ ids do not overlap MOBILE_BAR_ACTIONS at all — every action
1642
+ then lives in the drawer, and the tray has nothing to hold. */}
1643
+ {barActions.length > 0 ? (
1644
+ <div
1645
+ className={`${
1646
+ themeType === 'alternate'
1647
+ ? styles['hactions-alternate']
1648
+ : styles.hactions
1649
+ } ${debugIcons ? styles['debug-icons'] : ''}`}
1650
+ ref={actionItemsRef}
1651
+ >
1652
+ <ul>
1653
+ {barActions.map((setting, settingKey) => (
1232
1654
  <React.Fragment key={`setting-${settingKey}`}>
1233
1655
  {renderSetting(setting)}
1234
1656
  </React.Fragment>
1235
- ) : null
1236
- )}
1237
- </ul>
1238
- </div>
1657
+ ))}
1658
+ </ul>
1659
+ </div>
1660
+ ) : null}
1661
+
1662
+ {headerDrawer}
1239
1663
 
1240
1664
  <BusinessCardOcr
1241
1665
  isOpen={showBusinessCardOcr}