@visns-studio/visns-components 6.32.2 → 6.34.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 (41) hide show
  1. package/README.md +504 -0
  2. package/package.json +1 -1
  3. package/src/components/DataGrid.jsx +44 -2
  4. package/src/components/Field.jsx +32 -0
  5. package/src/components/Form.jsx +39 -2
  6. package/src/components/Navigation.jsx +353 -43
  7. package/src/components/Notification.jsx +13 -2
  8. package/src/components/TableFilter.jsx +14 -2
  9. package/src/components/auth/Login.jsx +9 -2
  10. package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
  11. package/src/components/auth/Profile.jsx +422 -26
  12. package/src/components/auth/authEndpoints.js +43 -0
  13. package/src/components/auth/passkeyClient.js +102 -0
  14. package/src/components/auth/passkeyPrompt.js +408 -0
  15. package/src/components/auth/profileLayout.js +45 -0
  16. package/src/components/controls/DataGridSearch.jsx +16 -2
  17. package/src/components/controls/DataGridSortSheet.jsx +2 -0
  18. package/src/components/generic/ActionButtons.jsx +1 -1
  19. package/src/components/generic/GenericAuth.jsx +197 -21
  20. package/src/components/generic/GenericDetail.jsx +95 -3
  21. package/src/components/generic/GenericMain.jsx +7 -0
  22. package/src/components/generic/StandardModal.jsx +71 -158
  23. package/src/components/sms/SmsCampaigns.jsx +2487 -0
  24. package/src/components/sms/SmsInbox.jsx +18 -0
  25. package/src/components/sms/SmsThreadPanel.jsx +20 -0
  26. package/src/components/sms/smsEndpoints.js +23 -0
  27. package/src/components/styles/DataGrid.module.scss +22 -0
  28. package/src/components/styles/Form.module.scss +100 -2
  29. package/src/components/styles/Navigation.module.scss +354 -15
  30. package/src/components/styles/Notification.module.scss +140 -8
  31. package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
  32. package/src/components/styles/Profile.module.scss +157 -0
  33. package/src/components/styles/Sms.module.scss +451 -0
  34. package/src/components/styles/StandardModal.module.scss +273 -0
  35. package/src/components/styles/global.css +24 -5
  36. package/src/components/utils/navCollapsed.js +201 -0
  37. package/src/components/utils/rowActionSettings.js +76 -0
  38. package/src/components/utils/usePasskeysEnabled.js +84 -0
  39. package/src/components/utils/useShellLayout.js +131 -0
  40. package/src/index.js +103 -1
  41. package/src/utils/rememberTab.js +70 -0
@@ -1,4 +1,10 @@
1
- import React, { useEffect, useRef, useState, useCallback } from 'react';
1
+ import React, {
2
+ useEffect,
3
+ useInsertionEffect,
4
+ useRef,
5
+ useState,
6
+ useCallback,
7
+ } from 'react';
2
8
  import { Link, useLocation, useNavigate } from 'react-router-dom';
3
9
  import {
4
10
  AudioLines,
@@ -26,6 +32,8 @@ import {
26
32
  Lock,
27
33
  MessageSquare,
28
34
  MessagesSquare,
35
+ PanelLeftClose,
36
+ PanelLeftOpen,
29
37
  TrendingUp,
30
38
  } from 'lucide-react';
31
39
  import CustomFetch from './Fetch';
@@ -40,6 +48,13 @@ import {
40
48
  DEFAULT_BADGE_TONE,
41
49
  } from './navBadges';
42
50
  import { drawerRows } from './navDrawer';
51
+ // The collapsed-rail preference: remembered in localStorage, published on
52
+ // <html> as `data-nav-collapsed`. See utils/navCollapsed.js.
53
+ import {
54
+ applyNavCollapsed,
55
+ getStoredNavCollapsed,
56
+ storeNavCollapsed,
57
+ } from './utils/navCollapsed';
43
58
  import styles from './styles/Navigation.module.scss';
44
59
 
45
60
  /**
@@ -444,6 +459,16 @@ function Navigation({
444
459
  * shared bar has no way to know any of it.
445
460
  */
446
461
  badges = {},
462
+ /*
463
+ * The square brand mark for the collapsed rail, if the project has one.
464
+ *
465
+ * `logo` is a wide lock-up — a mark plus a wordmark — and there is no room
466
+ * for a wordmark in a 64px strip. Passing nothing is fine and is the
467
+ * default: the logo plate then holds the collapse toggle alone, which
468
+ * reads as deliberate in a way a wordmark cropped to its first two letters
469
+ * does not.
470
+ */
471
+ collapsedLogo,
447
472
  }) {
448
473
  const [search, setSearch] = useState('');
449
474
  const [searchResultShow, setSearchResultShow] = useState(false);
@@ -463,6 +488,65 @@ function Navigation({
463
488
  const isNarrow = useMediaQuery(DRAWER_BREAKPOINT);
464
489
  const isCoarsePointer = useMediaQuery(COARSE_POINTER);
465
490
 
491
+ /*
492
+ * THE COLLAPSED RAIL.
493
+ *
494
+ * Only the sidebar layouts have a rail to collapse; the header bar is
495
+ * untouched by every line below, and writes no attribute.
496
+ *
497
+ * The initial value is read from localStorage in a LAZY INITIALISER, not
498
+ * in an effect. An effect runs after the first paint, so the rail would
499
+ * render 216px wide, the content would lay out beside it, and both would
500
+ * then jump — which is exactly the flash a remembered layout preference
501
+ * exists to avoid.
502
+ */
503
+ const isRailLayout = layout === 'simple' || layout === 'cms';
504
+ const [collapsed, setCollapsed] = useState(() =>
505
+ isRailLayout ? getStoredNavCollapsed() : false
506
+ );
507
+
508
+ /*
509
+ * Below 1024px the rail IS the drawer — a panel that slides over the page
510
+ * and is dismissed by tapping the backdrop — so there is nothing to
511
+ * collapse and nothing the extra width is being taken from. The state is
512
+ * kept (widening the window restores what the user chose); it simply does
513
+ * not apply.
514
+ */
515
+ const railCollapsed = isRailLayout && collapsed && !isNarrow;
516
+
517
+ /*
518
+ * Publish it on <html>, the way GenericAuth publishes `data-layout` and
519
+ * `data-density` — so the rail's own stylesheet, and a consuming app's,
520
+ * key off one attribute rather than off a class threaded through the tree.
521
+ * `useInsertionEffect` so the width is in place before the first paint.
522
+ */
523
+ useInsertionEffect(() => {
524
+ if (!isRailLayout) {
525
+ return undefined;
526
+ }
527
+
528
+ applyNavCollapsed(railCollapsed);
529
+
530
+ return () => applyNavCollapsed(false);
531
+ }, [isRailLayout, railCollapsed]);
532
+
533
+ /**
534
+ * Collapse, or expand. The preference is remembered against the browser,
535
+ * not the account: it is about this screen and this window.
536
+ */
537
+ /** The toggle is an icon in both states, so it is named in both. */
538
+ const collapseLabel = railCollapsed ? 'Expand menu' : 'Collapse menu';
539
+
540
+ const toggleCollapsed = useCallback(() => {
541
+ setCollapsed((previous) => {
542
+ const next = !previous;
543
+
544
+ storeNavCollapsed(next);
545
+
546
+ return next;
547
+ });
548
+ }, []);
549
+
466
550
  // The mobile drawer is a full-viewport fixed overlay; without a scroll
467
551
  // lock the page behind it keeps scrolling under the user's thumb.
468
552
  useEffect(() => {
@@ -675,6 +759,36 @@ function Navigation({
675
759
  const labelVar = { '--nav-action-label': JSON.stringify(label) };
676
760
  const IconComponent = SETTING_ICONS[n.icon];
677
761
 
762
+ /*
763
+ * The same tooltip the header chips carry, placed to the RIGHT of the
764
+ * rail and drawn only while the rail is collapsed — see the note in
765
+ * `renderNav`, which does this for the nav rows above these. Profile
766
+ * and Logout now behave like the bell, which was the only labelled
767
+ * thing in the rail. `actionLabel` already falls back to the humanised
768
+ * id, so a settings entry with no label of its own still says
769
+ * something.
770
+ */
771
+ const railTooltip =
772
+ railCollapsed && label
773
+ ? {
774
+ 'data-tooltip-id': 'system-tooltip',
775
+ 'data-tooltip-place': 'right',
776
+ 'data-tooltip-content': label,
777
+ }
778
+ : {};
779
+
780
+ // Same fallback the nav rows use: an action with no icon would
781
+ // collapse to an empty square, so it shows its initial instead.
782
+ const railInitial =
783
+ railCollapsed && !IconComponent && label
784
+ ? String(label).trim().charAt(0).toUpperCase()
785
+ : '';
786
+ const railInitialMark = railInitial ? (
787
+ <span className={styles['nav-initial']} aria-hidden="true">
788
+ {railInitial}
789
+ </span>
790
+ ) : null;
791
+
678
792
  // Same slot as the header's, publishing the row label the same way a
679
793
  // mounted component gets one (see the note above renderRailSetting).
680
794
  if (typeof renderers[n.id] === 'function') {
@@ -692,7 +806,14 @@ function Navigation({
692
806
  if (n.id === 'notifications') {
693
807
  return (
694
808
  <li style={labelVar}>
695
- <Notification setSystemAuth={setSystemAuth} />
809
+ {/* The bell draws its own trigger, so the placement is
810
+ the one thing it has to be told: its tooltip is
811
+ hard-placed below the chip for the header bar, and
812
+ below a row in a rail is on top of the next row. */}
813
+ <Notification
814
+ setSystemAuth={setSystemAuth}
815
+ tooltipPlace="right"
816
+ />
696
817
  </li>
697
818
  );
698
819
  } else if (n.id === 'businessCardOcr') {
@@ -702,9 +823,11 @@ function Navigation({
702
823
  onClick={() => setShowBusinessCardOcr(true)}
703
824
  className={activeClass}
704
825
  type="button"
826
+ aria-label={label}
827
+ {...railTooltip}
705
828
  >
706
829
  <CreditCard size={19} strokeWidth={2} />
707
- <span>{label}</span>
830
+ <span className={styles['nav-label']}>{label}</span>
708
831
  </button>
709
832
  </li>
710
833
  );
@@ -725,11 +848,14 @@ function Navigation({
725
848
  onClick={handleLogout}
726
849
  className={activeClass}
727
850
  type="button"
851
+ aria-label={label}
852
+ {...railTooltip}
728
853
  >
729
854
  {IconComponent && (
730
855
  <IconComponent size={19} strokeWidth={2} />
731
856
  )}
732
- <span>{label}</span>
857
+ {railInitialMark}
858
+ <span className={styles['nav-label']}>{label}</span>
733
859
  </button>
734
860
  </li>
735
861
  );
@@ -741,6 +867,8 @@ function Navigation({
741
867
  to={n.url}
742
868
  className={`${activeClass}`}
743
869
  target={n.target ? n.target : '_self'}
870
+ aria-label={label}
871
+ {...railTooltip}
744
872
  // The rail is the mobile drawer below 1024px; navigating
745
873
  // from it has to close the drawer, as the nav list does.
746
874
  onClick={() => setOpen(false)}
@@ -748,7 +876,8 @@ function Navigation({
748
876
  {IconComponent && (
749
877
  <IconComponent size={19} strokeWidth={2} />
750
878
  )}
751
- <span>{label}</span>
879
+ {railInitialMark}
880
+ <span className={styles['nav-label']}>{label}</span>
752
881
  </Link>
753
882
  </li>
754
883
  );
@@ -767,26 +896,65 @@ function Navigation({
767
896
  * point of declaration (`const [, setActiveDropdown]`): it re-rendered the
768
897
  * whole bar on every hover to change nothing, because the only thing it
769
898
  * 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.
899
+ * That poke is `pinDropdown` now, and it is called from the tap path as
900
+ * well — the dropdown has to be placed before it is shown, wherever the
901
+ * pointer has or has not been.
773
902
  */
774
903
  const [openGroup, setOpenGroup] = useState(null);
775
904
  const navWrapRef = useRef(null);
776
905
 
777
906
  /**
778
- * Let an open dropdown out of the horizontally-scrolling nav strip.
907
+ * Put an item's dropdown directly underneath the item.
908
+ *
909
+ * The panel is `position: fixed` (the stylesheet carries the long note):
910
+ * that is what keeps it out of the strip's `overflow-x: auto` clip, since
911
+ * a scroll container clips on BOTH axes and the alternative — flipping
912
+ * the strip to `overflow-x: visible` — destroyed the scroll container,
913
+ * reset `scrollLeft` to 0 and slid the item out from under the pointer
914
+ * while the menu was opening. The price of fixed is that the panel no
915
+ * longer inherits the item's position, so measure the <li> and hand the
916
+ * two coordinates down as custom properties. The scroller is never
917
+ * touched.
779
918
  *
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.
919
+ * The left edge is clamped into the viewport, so the last group in the
920
+ * strip opens its 220px panel on screen rather than off the right edge.
783
921
  */
784
- const liftNavClipping = (lift) => {
785
- if (navWrapRef.current) {
786
- navWrapRef.current.style.overflowX = lift ? 'visible' : 'auto';
922
+ const pinDropdown = (li) => {
923
+ if (!li) {
924
+ return;
787
925
  }
926
+
927
+ const rect = li.getBoundingClientRect();
928
+ li.style.setProperty('--nav-sub-top', `${rect.bottom}px`);
929
+ li.style.setProperty(
930
+ '--nav-sub-left',
931
+ `${Math.max(8, Math.min(rect.left, window.innerWidth - 220 - 8))}px`
932
+ );
788
933
  };
789
934
 
935
+ // A scrolled strip moves its items but not a fixed panel, so a menu that
936
+ // is already open would sail away from the group it belongs to. Re-pin
937
+ // whatever is open as the strip scrolls — `:hover` is the mouse, and
938
+ // `:focus-within` covers both the keyboard and the group a touch reader
939
+ // has tapped open. Passive: this only reads geometry and writes two
940
+ // custom properties, it never cancels the scroll.
941
+ useEffect(() => {
942
+ const strip = navWrapRef.current;
943
+
944
+ if (!strip) {
945
+ return undefined;
946
+ }
947
+
948
+ const onScroll = () => {
949
+ strip
950
+ .querySelectorAll('li:hover, li:focus-within')
951
+ .forEach(pinDropdown);
952
+ };
953
+
954
+ strip.addEventListener('scroll', onScroll, { passive: true });
955
+ return () => strip.removeEventListener('scroll', onScroll);
956
+ }, []);
957
+
790
958
  // A tap anywhere else closes the group a touch reader opened. Without it
791
959
  // the only way to dismiss the menu is to tap the heading again, which is
792
960
  // not how any other menu on the device behaves. Mouse users never get
@@ -799,7 +967,6 @@ function Navigation({
799
967
  const onDown = (event) => {
800
968
  if (!navWrapRef.current?.contains(event.target)) {
801
969
  setOpenGroup(null);
802
- liftNavClipping(false);
803
970
  }
804
971
  };
805
972
 
@@ -809,10 +976,42 @@ function Navigation({
809
976
 
810
977
  const renderNav = (n) => {
811
978
  let navItemClasses;
812
- const NavItemIcon =
813
- layout === 'simple' || layout === 'cms'
814
- ? NAV_ICONS[n.icon || n.id]
815
- : undefined;
979
+ const NavItemIcon = isRailLayout
980
+ ? NAV_ICONS[n.icon || n.id]
981
+ : undefined;
982
+
983
+ /*
984
+ * The rail's rows get the same tooltip every account chip in the
985
+ * header has always had — `data-tooltip-*` read by the one
986
+ * `<Tooltip id="system-tooltip">` GenericAuth mounts.
987
+ *
988
+ * ONLY WHILE COLLAPSED, and that is the whole point of it: an icon
989
+ * strip is unreadable without one, and a tooltip repeating a label
990
+ * that is already printed six pixels to its right is noise. The
991
+ * attributes are omitted rather than the tooltip disabled — react-
992
+ * tooltip shows nothing for an anchor with no `data-tooltip-content`.
993
+ *
994
+ * `place="right"`, not the header's "bottom": a tooltip under a row
995
+ * in a vertical list covers the next row, which is the one you are
996
+ * about to read. To the right of the rail it covers page content
997
+ * nobody is pointing at.
998
+ *
999
+ * No `title` attribute anywhere near this — the native tooltip and
1000
+ * react-tooltip both fire, half a second apart, and you get the label
1001
+ * twice in two different boxes.
1002
+ *
1003
+ * Spread as an object rather than written inline so the HEADER layout
1004
+ * renders exactly the attributes it rendered before: on `full` this
1005
+ * is empty and nothing is added.
1006
+ */
1007
+ const railTooltip =
1008
+ railCollapsed && n.label
1009
+ ? {
1010
+ 'data-tooltip-id': 'system-tooltip',
1011
+ 'data-tooltip-place': 'right',
1012
+ 'data-tooltip-content': n.label,
1013
+ }
1014
+ : {};
816
1015
 
817
1016
  if (n.class.includes('active')) {
818
1017
  navItemClasses =
@@ -828,15 +1027,14 @@ function Navigation({
828
1027
 
829
1028
  const hasChildren = n.children && n.children.length > 0;
830
1029
 
831
- const handleMouseEnter = () => {
1030
+ // Place the panel before it is shown. Hover is the mouse; focus is
1031
+ // the keyboard, and React's onFocus bubbles, so focus landing on the
1032
+ // group heading OR on any row inside the open menu pins it just the
1033
+ // same. Nothing to undo on the way out: an unopened panel is
1034
+ // `display: none` wherever its coordinates happen to say.
1035
+ const handlePin = (event) => {
832
1036
  if (hasChildren) {
833
- liftNavClipping(true);
834
- }
835
- };
836
-
837
- const handleMouseLeave = () => {
838
- if (hasChildren) {
839
- liftNavClipping(false);
1037
+ pinDropdown(event.currentTarget);
840
1038
  }
841
1039
  };
842
1040
 
@@ -880,7 +1078,6 @@ function Navigation({
880
1078
  onClick={() => {
881
1079
  setOpen(false);
882
1080
  setOpenGroup(null);
883
- liftNavClipping(false);
884
1081
  }}
885
1082
  >
886
1083
  {child.label}
@@ -903,10 +1100,45 @@ function Navigation({
903
1100
  */
904
1101
  const itemBadge = navBadge(badges, n);
905
1102
  const ariaLabel = badgeAriaLabel(n.label, itemBadge);
1103
+ /*
1104
+ * The label, wrapped in a span ONLY WHILE THE RAIL IS COLLAPSED.
1105
+ *
1106
+ * A collapsed row shows its icon alone, and a bare text node cannot be
1107
+ * hidden — only deleted, which takes the row's accessible name with
1108
+ * it. Wrapped, it is clipped to a pixel and stays in the accessibility
1109
+ * tree where the screen reader and the tooltip both still find it.
1110
+ *
1111
+ * CONDITIONAL, not permanent, and that is the whole lesson of the
1112
+ * first attempt: this rail's own stylesheet styles `span` as a
1113
+ * DESCENDANT of `.nav-item` — the rule was written for the group
1114
+ * heading, which used to be a `<span>` — so an unconditional wrapper
1115
+ * turned every expanded row into a `--primary-color` block with 1.25em
1116
+ * of padding and squeezed the icon beside it to nothing. Emitting the
1117
+ * bare text node when expanded, exactly as before, means there is no
1118
+ * selector to audit: the expanded rail renders what it has always
1119
+ * rendered, in both layouts.
1120
+ *
1121
+ * An item with no icon would collapse to an empty square, so it falls
1122
+ * back to the first letter of its label in the icon's slot — again
1123
+ * only while collapsed, where it is the only thing in the row.
1124
+ */
1125
+ const railInitial =
1126
+ railCollapsed && !NavItemIcon && n.label
1127
+ ? String(n.label).trim().charAt(0).toUpperCase()
1128
+ : '';
906
1129
  const contents = (
907
1130
  <>
908
1131
  {NavItemIcon ? <NavItemIcon size={18} /> : null}
909
- {n.label}
1132
+ {railInitial ? (
1133
+ <span className={styles['nav-initial']} aria-hidden="true">
1134
+ {railInitial}
1135
+ </span>
1136
+ ) : null}
1137
+ {railCollapsed ? (
1138
+ <span className={styles['nav-label']}>{n.label}</span>
1139
+ ) : (
1140
+ n.label
1141
+ )}
910
1142
  <BadgePill badge={itemBadge} />
911
1143
  </>
912
1144
  );
@@ -914,23 +1146,35 @@ function Navigation({
914
1146
  return (
915
1147
  <li
916
1148
  className={navItemClasses}
917
- onMouseEnter={handleMouseEnter}
918
- onMouseLeave={handleMouseLeave}
1149
+ onMouseEnter={handlePin}
1150
+ onFocus={handlePin}
919
1151
  >
920
- {n.url ? (
1152
+ {n.url && !(hasChildren && (isCoarsePointer || railCollapsed)) ? (
921
1153
  // Close the mobile drawer when navigating; no-op for the
922
1154
  // header layout where the drawer state is never opened.
1155
+ //
1156
+ // A GROUP THAT ALSO HAS A URL goes the other way on a
1157
+ // coarse pointer. A <Link> carries no `aria-expanded`, and
1158
+ // the touch stylesheet opens the panel off that attribute
1159
+ // alone — so on a touch screen the only thing a tap on
1160
+ // such a heading could ever do was navigate, and the pages
1161
+ // inside the group were unreachable. There the button
1162
+ // branch below wins and the parent page is reached from
1163
+ // the menu instead (its first row, by convention). On a
1164
+ // fine pointer the link stays: hover opens the panel, so
1165
+ // nothing is lost by keeping the heading clickable.
923
1166
  <Link
924
1167
  to={n.url}
925
1168
  aria-label={ariaLabel}
926
1169
  onClick={() => setOpen(false)}
1170
+ {...railTooltip}
927
1171
  >
928
1172
  {contents}
929
1173
  </Link>
930
1174
  ) : hasChildren ? (
931
1175
  /*
932
- * A group heading — no page of its own, so there is
933
- * nothing to link to, but it still has to be REACHABLE.
1176
+ * A group heading — usually no page of its own, so there
1177
+ * is nothing to link to, but it still has to be REACHABLE.
934
1178
  * As a <span> it was not focusable and the menu opened on
935
1179
  * :hover alone, which left every page inside a group
936
1180
  * unreachable by keyboard and untappable on a touch
@@ -969,14 +1213,40 @@ function Navigation({
969
1213
  what made the menu impossible to open by tapping,
970
1214
  since the tap that opened it removed the focus
971
1215
  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. */
1216
+ and pin the panel to the item the way a hover does,
1217
+ since a fixed panel has no position of its own until
1218
+ somebody measures the item for it. */
975
1219
  onClick={(e) => {
1220
+ /*
1221
+ * COLLAPSED: there is nowhere for a submenu to go.
1222
+ * A 64px strip cannot hold a list of child labels,
1223
+ * and a flyout anchored to a row is a second
1224
+ * floating surface with its own placement,
1225
+ * clipping and dismissal problems. Expanding the
1226
+ * rail is the simple answer that cannot go wrong:
1227
+ * the group is then an ordinary heading in an
1228
+ * ordinary rail, and the next hover opens it.
1229
+ *
1230
+ * `n.url` groups take this path too while
1231
+ * collapsed (see the branch condition above) —
1232
+ * otherwise the tap would navigate to the parent
1233
+ * page and the children would be unreachable
1234
+ * without expanding the rail by hand.
1235
+ */
1236
+ if (railCollapsed) {
1237
+ toggleCollapsed();
1238
+
1239
+ return;
1240
+ }
1241
+
976
1242
  if (isCoarsePointer) {
977
1243
  const next = openGroup === n.id ? null : n.id;
978
1244
  setOpenGroup(next);
979
- liftNavClipping(next !== null);
1245
+
1246
+ if (next !== null) {
1247
+ pinDropdown(e.currentTarget.closest('li'));
1248
+ }
1249
+
980
1250
  return;
981
1251
  }
982
1252
 
@@ -984,11 +1254,14 @@ function Navigation({
984
1254
  e.currentTarget.blur();
985
1255
  }
986
1256
  }}
1257
+ {...railTooltip}
987
1258
  >
988
1259
  {contents}
989
1260
  </button>
990
1261
  ) : (
991
- <span aria-label={ariaLabel}>{contents}</span>
1262
+ <span aria-label={ariaLabel} {...railTooltip}>
1263
+ {contents}
1264
+ </span>
992
1265
  )}
993
1266
  {renderChildren()}
994
1267
  </li>
@@ -1463,18 +1736,55 @@ function Navigation({
1463
1736
  return layout === 'simple' || layout === 'cms' ? (
1464
1737
  <div className={styles.cwrap}>
1465
1738
  <aside
1739
+ id="nav-rail"
1466
1740
  className={[
1467
1741
  styles['aside--cms'],
1468
1742
  isOpen ? styles['aside--cms--open'] : '',
1469
1743
  actionsInSidebar ? styles['aside--cms--rail'] : '',
1744
+ railCollapsed ? styles['aside--cms--collapsed'] : '',
1470
1745
  ]
1471
1746
  .filter(Boolean)
1472
1747
  .join(' ')}
1473
1748
  >
1474
1749
  <div className={styles.logo}>
1475
- <Link to="/">
1476
- <img src={logo} alt="System Logo" />
1477
- </Link>
1750
+ {/* A square mark for the 64px strip when the project has
1751
+ one. With none, the plate holds the collapse toggle
1752
+ alone — a wide lock-up cropped to its first two letters
1753
+ is worse than no logo, and the plate is still the
1754
+ brand-coloured band it always was. */}
1755
+ {railCollapsed && !collapsedLogo ? null : (
1756
+ <Link to="/">
1757
+ <img
1758
+ src={
1759
+ railCollapsed && collapsedLogo
1760
+ ? collapsedLogo
1761
+ : logo
1762
+ }
1763
+ alt="System Logo"
1764
+ />
1765
+ </Link>
1766
+ )}
1767
+ {/* Desktop only — below 1024px the rail is a drawer and
1768
+ there is no width to reclaim. Hidden in CSS as well,
1769
+ because `isNarrow` resolves from a media query that
1770
+ answers `false` for one render on a cold load. */}
1771
+ <button
1772
+ type="button"
1773
+ className={styles['rail-toggle']}
1774
+ aria-expanded={!railCollapsed}
1775
+ aria-controls="nav-rail"
1776
+ data-tooltip-id="system-tooltip"
1777
+ data-tooltip-place="right"
1778
+ data-tooltip-content={collapseLabel}
1779
+ aria-label={collapseLabel}
1780
+ onClick={toggleCollapsed}
1781
+ >
1782
+ {railCollapsed ? (
1783
+ <PanelLeftOpen size={18} strokeWidth={2} />
1784
+ ) : (
1785
+ <PanelLeftClose size={18} strokeWidth={2} />
1786
+ )}
1787
+ </button>
1478
1788
  </div>
1479
1789
  <div className={styles.awrap}>
1480
1790
  <nav className={styles.navwrap} ref={navWrapRef}>
@@ -100,7 +100,18 @@ function Notification(props) {
100
100
  const [show, setShow] = useState(false);
101
101
  const [loading, setLoading] = useState(false);
102
102
  const [processingIds, setProcessingIds] = useState([]);
103
- const { setSystemAuth, echo = null, channel = null } = props;
103
+ const {
104
+ setSystemAuth,
105
+ echo = null,
106
+ channel = null,
107
+ /**
108
+ * Where the trigger's tooltip opens. 'bottom' is the header chip,
109
+ * which is what this has always been and what every existing mount
110
+ * keeps. The sidebar rail passes 'right': below a row in a vertical
111
+ * list is on top of the next row.
112
+ */
113
+ tooltipPlace = 'bottom',
114
+ } = props;
104
115
 
105
116
  // Live means *confirmed*: Echo hands back a channel object long before
106
117
  // /broadcasting/auth has approved it, and treating that as live is how a
@@ -451,7 +462,7 @@ function Notification(props) {
451
462
  className={styles.notificationTrigger}
452
463
  onClick={toggleNotification}
453
464
  data-tooltip-id="system-tooltip"
454
- data-tooltip-place="bottom"
465
+ data-tooltip-place={tooltipPlace}
455
466
  data-tooltip-content="Notifications"
456
467
  aria-label="Notifications"
457
468
  >
@@ -34,8 +34,10 @@ function TableFilter({
34
34
  // `.flatFilter` block in the stylesheet), promoted to an opt-in.
35
35
  //
36
36
  // A vertical rail spends a fifth of the page on navigation. Detail pages
37
- // now ask for the strip unconditionally (see GenericDetail); index pages
38
- // and Profile still get the rail, so the default here is unchanged.
37
+ // now ask for the strip unconditionally (see GenericDetail); Profile asks
38
+ // for it on the sidebar shells and keeps the rail on the header-bar one
39
+ // (see auth/profileLayout.js); index pages still get the rail unless the
40
+ // host says otherwise. The default here is unchanged.
39
41
  //
40
42
  // In this variant the strip also carries real tab semantics — tablist /
41
43
  // tab / aria-selected, one tab stop for the whole strip, and arrow keys to
@@ -565,6 +567,14 @@ function TableFilter({
565
567
  }
566
568
  : { 'aria-current': isSelected ? 'page' : undefined };
567
569
 
570
+ // The strip's own tooltip. A tab's label is a noun ("Documents") and
571
+ // what a reader wants back from a hover is the verb — which verb it is
572
+ // depends on whether they are standing on it already. Both branches
573
+ // below carry it: a detail page's strip renders the anchor and an
574
+ // index page's `simple` strip the button, and one strip must not
575
+ // answer a hover differently depending on where it is drawn.
576
+ const hint = isSelected ? `You are on ${d.label}` : `Show ${d.label}`;
577
+
568
578
  if (type === 'simple') {
569
579
  return (
570
580
  <li className={styles[d.class]}>
@@ -574,6 +584,7 @@ function TableFilter({
574
584
  data-url={d.url || ''}
575
585
  data-value={d.hasOwnProperty('value') ? d.value : ''}
576
586
  data-active={isSelected ? 'true' : undefined}
587
+ title={hint}
577
588
  onClick={filterTable}
578
589
  {...a11y}
579
590
  >
@@ -591,6 +602,7 @@ function TableFilter({
591
602
  data-url={d.url || ''}
592
603
  data-value={d.hasOwnProperty('value') ? d.value : ''}
593
604
  data-active={isSelected ? 'true' : undefined}
605
+ title={hint}
594
606
  onClick={filterTable}
595
607
  className={parentClass}
596
608
  {...a11y}