@visns-studio/visns-components 6.32.2 → 6.32.3

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 CHANGED
@@ -9,6 +9,40 @@ A comprehensive React component library used by the VISNS Studio team for CRM an
9
9
 
10
10
  VISNS Components is a React-based UI component library that provides a set of reusable, consistent, and customizable components for building web applications. It includes components for authentication, data grids, forms, navigation, and more, designed to work seamlessly together.
11
11
 
12
+ ## Recent Updates (v6.32.3)
13
+
14
+ ### The nav dropdowns open where the pointer left them
15
+
16
+ A group in the header bar — Reports, say — opened its sub-menu and then shut
17
+ it again the instant the pointer arrived, and on a strip scrolled to its end
18
+ the item visibly jumped left as it happened. The bar (`.navwrap`) is
19
+ `overflow-x: auto` so it can scroll on narrow screens, and a scroll container
20
+ clips on BOTH axes however `visible` the other one is written, so the panel
21
+ hanging below the bar was being cut off at the bar's bottom edge. The library
22
+ had been working around that by flipping the strip to `overflow-x: visible`
23
+ on hover — which destroys the scroll container: the browser resets
24
+ `scrollLeft` to 0, the item slides out from under the pointer, and the menu
25
+ loses the hover that opened it.
26
+
27
+ The panel is now `position: fixed`, placed per item from coordinates the
28
+ component measures (`--nav-sub-top` / `--nav-sub-left`, set on the `<li>` from
29
+ its own bounding rect and clamped into the viewport so the last group does not
30
+ open off the right edge). A fixed panel is outside the scroller's flow, so the
31
+ clip never applies and the strip's scroll position is never touched. Pinning
32
+ happens on `mouseenter` and on `focus` — and since React's `onFocus` bubbles,
33
+ tabbing to a group heading now opens and places its menu too, where keyboard
34
+ users previously got a menu that `:focus-within` opened inside the clip and
35
+ nothing un-clipped. Scrolling the strip re-pins whatever is open, so a wheeled
36
+ bar keeps its menu under its item. A group that ALSO has a `url` renders its
37
+ button (and its real `aria-expanded`) on a coarse pointer instead of a plain
38
+ link, so a tap opens the menu rather than only navigating; on a fine pointer
39
+ the heading stays a clickable link.
40
+
41
+ One caveat comes with `position: fixed`: an ancestor carrying `transform`,
42
+ `filter` or `contain: paint` becomes the containing block for fixed
43
+ descendants and would trap the panel inside the bar again. Nothing in the
44
+ shipped layouts does that.
45
+
12
46
  ## Recent Updates (v6.32.2)
13
47
 
14
48
  ### Boxed overview: no panel around the cards
package/package.json CHANGED
@@ -93,7 +93,7 @@
93
93
  "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
94
94
  },
95
95
  "name": "@visns-studio/visns-components",
96
- "version": "6.32.2",
96
+ "version": "6.32.3",
97
97
  "description": "Various packages to assist in the development of our Custom Applications.",
98
98
  "main": "src/index.js",
99
99
  "files": [
@@ -767,26 +767,65 @@ function Navigation({
767
767
  * point of declaration (`const [, setActiveDropdown]`): it re-rendered the
768
768
  * whole bar on every hover to change nothing, because the only thing it
769
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.
770
+ * That poke is `pinDropdown` now, and it is called from the tap path as
771
+ * well — the dropdown has to be placed before it is shown, wherever the
772
+ * pointer has or has not been.
773
773
  */
774
774
  const [openGroup, setOpenGroup] = useState(null);
775
775
  const navWrapRef = useRef(null);
776
776
 
777
777
  /**
778
- * Let an open dropdown out of the horizontally-scrolling nav strip.
778
+ * Put an item's dropdown directly underneath the item.
779
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.
780
+ * The panel is `position: fixed` (the stylesheet carries the long note):
781
+ * that is what keeps it out of the strip's `overflow-x: auto` clip, since
782
+ * a scroll container clips on BOTH axes and the alternative — flipping
783
+ * the strip to `overflow-x: visible` — destroyed the scroll container,
784
+ * reset `scrollLeft` to 0 and slid the item out from under the pointer
785
+ * while the menu was opening. The price of fixed is that the panel no
786
+ * longer inherits the item's position, so measure the <li> and hand the
787
+ * two coordinates down as custom properties. The scroller is never
788
+ * touched.
789
+ *
790
+ * The left edge is clamped into the viewport, so the last group in the
791
+ * strip opens its 220px panel on screen rather than off the right edge.
783
792
  */
784
- const liftNavClipping = (lift) => {
785
- if (navWrapRef.current) {
786
- navWrapRef.current.style.overflowX = lift ? 'visible' : 'auto';
793
+ const pinDropdown = (li) => {
794
+ if (!li) {
795
+ return;
787
796
  }
797
+
798
+ const rect = li.getBoundingClientRect();
799
+ li.style.setProperty('--nav-sub-top', `${rect.bottom}px`);
800
+ li.style.setProperty(
801
+ '--nav-sub-left',
802
+ `${Math.max(8, Math.min(rect.left, window.innerWidth - 220 - 8))}px`
803
+ );
788
804
  };
789
805
 
806
+ // A scrolled strip moves its items but not a fixed panel, so a menu that
807
+ // is already open would sail away from the group it belongs to. Re-pin
808
+ // whatever is open as the strip scrolls — `:hover` is the mouse, and
809
+ // `:focus-within` covers both the keyboard and the group a touch reader
810
+ // has tapped open. Passive: this only reads geometry and writes two
811
+ // custom properties, it never cancels the scroll.
812
+ useEffect(() => {
813
+ const strip = navWrapRef.current;
814
+
815
+ if (!strip) {
816
+ return undefined;
817
+ }
818
+
819
+ const onScroll = () => {
820
+ strip
821
+ .querySelectorAll('li:hover, li:focus-within')
822
+ .forEach(pinDropdown);
823
+ };
824
+
825
+ strip.addEventListener('scroll', onScroll, { passive: true });
826
+ return () => strip.removeEventListener('scroll', onScroll);
827
+ }, []);
828
+
790
829
  // A tap anywhere else closes the group a touch reader opened. Without it
791
830
  // the only way to dismiss the menu is to tap the heading again, which is
792
831
  // not how any other menu on the device behaves. Mouse users never get
@@ -799,7 +838,6 @@ function Navigation({
799
838
  const onDown = (event) => {
800
839
  if (!navWrapRef.current?.contains(event.target)) {
801
840
  setOpenGroup(null);
802
- liftNavClipping(false);
803
841
  }
804
842
  };
805
843
 
@@ -828,15 +866,14 @@ function Navigation({
828
866
 
829
867
  const hasChildren = n.children && n.children.length > 0;
830
868
 
831
- const handleMouseEnter = () => {
832
- if (hasChildren) {
833
- liftNavClipping(true);
834
- }
835
- };
836
-
837
- const handleMouseLeave = () => {
869
+ // Place the panel before it is shown. Hover is the mouse; focus is
870
+ // the keyboard, and React's onFocus bubbles, so focus landing on the
871
+ // group heading OR on any row inside the open menu pins it just the
872
+ // same. Nothing to undo on the way out: an unopened panel is
873
+ // `display: none` wherever its coordinates happen to say.
874
+ const handlePin = (event) => {
838
875
  if (hasChildren) {
839
- liftNavClipping(false);
876
+ pinDropdown(event.currentTarget);
840
877
  }
841
878
  };
842
879
 
@@ -880,7 +917,6 @@ function Navigation({
880
917
  onClick={() => {
881
918
  setOpen(false);
882
919
  setOpenGroup(null);
883
- liftNavClipping(false);
884
920
  }}
885
921
  >
886
922
  {child.label}
@@ -914,12 +950,23 @@ function Navigation({
914
950
  return (
915
951
  <li
916
952
  className={navItemClasses}
917
- onMouseEnter={handleMouseEnter}
918
- onMouseLeave={handleMouseLeave}
953
+ onMouseEnter={handlePin}
954
+ onFocus={handlePin}
919
955
  >
920
- {n.url ? (
956
+ {n.url && !(hasChildren && isCoarsePointer) ? (
921
957
  // Close the mobile drawer when navigating; no-op for the
922
958
  // header layout where the drawer state is never opened.
959
+ //
960
+ // A GROUP THAT ALSO HAS A URL goes the other way on a
961
+ // coarse pointer. A <Link> carries no `aria-expanded`, and
962
+ // the touch stylesheet opens the panel off that attribute
963
+ // alone — so on a touch screen the only thing a tap on
964
+ // such a heading could ever do was navigate, and the pages
965
+ // inside the group were unreachable. There the button
966
+ // branch below wins and the parent page is reached from
967
+ // the menu instead (its first row, by convention). On a
968
+ // fine pointer the link stays: hover opens the panel, so
969
+ // nothing is lost by keeping the heading clickable.
923
970
  <Link
924
971
  to={n.url}
925
972
  aria-label={ariaLabel}
@@ -929,8 +976,8 @@ function Navigation({
929
976
  </Link>
930
977
  ) : hasChildren ? (
931
978
  /*
932
- * A group heading — no page of its own, so there is
933
- * nothing to link to, but it still has to be REACHABLE.
979
+ * A group heading — usually no page of its own, so there
980
+ * is nothing to link to, but it still has to be REACHABLE.
934
981
  * As a <span> it was not focusable and the menu opened on
935
982
  * :hover alone, which left every page inside a group
936
983
  * unreachable by keyboard and untappable on a touch
@@ -969,14 +1016,18 @@ function Navigation({
969
1016
  what made the menu impossible to open by tapping,
970
1017
  since the tap that opened it removed the focus
971
1018
  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. */
1019
+ and pin the panel to the item the way a hover does,
1020
+ since a fixed panel has no position of its own until
1021
+ somebody measures the item for it. */
975
1022
  onClick={(e) => {
976
1023
  if (isCoarsePointer) {
977
1024
  const next = openGroup === n.id ? null : n.id;
978
1025
  setOpenGroup(next);
979
- liftNavClipping(next !== null);
1026
+
1027
+ if (next !== null) {
1028
+ pinDropdown(e.currentTarget.closest('li'));
1029
+ }
1030
+
980
1031
  return;
981
1032
  }
982
1033
 
@@ -363,13 +363,29 @@
363
363
 
364
364
  > ul {
365
365
  width: 220px;
366
- position: absolute;
367
- /* Anchored explicitly rather than left wherever its static
368
- position lands: the item is now a full-height flex box, so
369
- an un-anchored absolute child is at the mercy of the flex
370
- line. */
371
- top: 100%;
372
- left: 0;
366
+ /* FIXED, not absolute, and the strip above is the reason.
367
+ `.navwrap` is `overflow-x: auto`, and a scroll container
368
+ clips on BOTH axes however `visible` the other one is
369
+ written — so an absolutely-positioned panel hanging below
370
+ the bar was cut off at the bar's own bottom edge. The old
371
+ way out was to flip the strip to `overflow-x: visible`
372
+ while a group was hovered, which destroys the scroll
373
+ container: the browser resets `scrollLeft` to 0, the item
374
+ slides out from under the pointer, and the menu closes
375
+ itself in the same gesture. A fixed panel is outside the
376
+ scroller's flow altogether, so the clip never applies and
377
+ the scroller is never touched — Navigation.jsx measures the
378
+ item and hands its two coordinates down as custom
379
+ properties. The fallbacks below are the old anchoring, for
380
+ the frame before a measurement lands.
381
+
382
+ ONE TRADE-OFF: an ancestor carrying `transform`, `filter`
383
+ or `contain: paint` becomes the containing block for its
384
+ fixed descendants, and would trap the panel inside the bar
385
+ again. */
386
+ position: fixed;
387
+ top: var(--nav-sub-top, 100%);
388
+ left: var(--nav-sub-left, 0);
373
389
  height: auto;
374
390
  margin: 0;
375
391
  /* The rows used to sit flush against the panel edge, which is
@@ -552,9 +568,15 @@
552
568
 
553
569
  > ul {
554
570
  width: 220px;
555
- position: absolute;
556
- top: 100%;
557
- left: 0;
571
+ /* Fixed for the same reason as `.nav-item` above — the strip
572
+ is a scroll container and clips on both axes, and flipping
573
+ its overflow to escape the clip reset `scrollLeft` out from
574
+ under the pointer. Coordinates come from Navigation.jsx per
575
+ item; see the long note there for the transform/filter
576
+ caveat. */
577
+ position: fixed;
578
+ top: var(--nav-sub-top, 100%);
579
+ left: var(--nav-sub-left, 0);
558
580
  height: auto;
559
581
  margin: 0;
560
582
  padding: 4px;