@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.
- package/README.md +504 -0
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +44 -2
- package/src/components/Field.jsx +32 -0
- package/src/components/Form.jsx +39 -2
- package/src/components/Navigation.jsx +353 -43
- package/src/components/Notification.jsx +13 -2
- package/src/components/TableFilter.jsx +14 -2
- package/src/components/auth/Login.jsx +9 -2
- package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
- package/src/components/auth/Profile.jsx +422 -26
- package/src/components/auth/authEndpoints.js +43 -0
- package/src/components/auth/passkeyClient.js +102 -0
- package/src/components/auth/passkeyPrompt.js +408 -0
- package/src/components/auth/profileLayout.js +45 -0
- package/src/components/controls/DataGridSearch.jsx +16 -2
- package/src/components/controls/DataGridSortSheet.jsx +2 -0
- package/src/components/generic/ActionButtons.jsx +1 -1
- package/src/components/generic/GenericAuth.jsx +197 -21
- package/src/components/generic/GenericDetail.jsx +95 -3
- package/src/components/generic/GenericMain.jsx +7 -0
- package/src/components/generic/StandardModal.jsx +71 -158
- package/src/components/sms/SmsCampaigns.jsx +2487 -0
- package/src/components/sms/SmsInbox.jsx +18 -0
- package/src/components/sms/SmsThreadPanel.jsx +20 -0
- package/src/components/sms/smsEndpoints.js +23 -0
- package/src/components/styles/DataGrid.module.scss +22 -0
- package/src/components/styles/Form.module.scss +100 -2
- package/src/components/styles/Navigation.module.scss +354 -15
- package/src/components/styles/Notification.module.scss +140 -8
- package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
- package/src/components/styles/Profile.module.scss +157 -0
- package/src/components/styles/Sms.module.scss +451 -0
- package/src/components/styles/StandardModal.module.scss +273 -0
- package/src/components/styles/global.css +24 -5
- package/src/components/utils/navCollapsed.js +201 -0
- package/src/components/utils/rowActionSettings.js +76 -0
- package/src/components/utils/usePasskeysEnabled.js +84 -0
- package/src/components/utils/useShellLayout.js +131 -0
- package/src/index.js +103 -1
- package/src/utils/rememberTab.js +70 -0
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
771
|
-
*
|
|
772
|
-
*
|
|
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
|
-
*
|
|
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
|
|
781
|
-
*
|
|
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
|
|
785
|
-
if (
|
|
786
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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={
|
|
918
|
-
|
|
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
|
|
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
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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 {
|
|
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=
|
|
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);
|
|
38
|
-
//
|
|
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}
|