@jsenv/navi 0.29.3 → 0.29.5

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.
@@ -76,7 +76,7 @@ const useActionStatus = (action) => {
76
76
  };
77
77
  };
78
78
 
79
- installImportMetaCssBuild(import.meta);const css$U = /* css */`
79
+ installImportMetaCssBuild(import.meta);const css$W = /* css */`
80
80
  .action_error {
81
81
  margin-top: 0;
82
82
  margin-bottom: 20px;
@@ -101,7 +101,7 @@ const ActionRenderer = ({
101
101
  children,
102
102
  disabled
103
103
  }) => {
104
- import.meta.css = [css$U, "@jsenv/navi/src/action/action_renderer.jsx"];
104
+ import.meta.css = [css$W, "@jsenv/navi/src/action/action_renderer.jsx"];
105
105
  if (action === undefined) {
106
106
  throw new Error("ActionRenderer requires an action to render, but none was provided.");
107
107
  }
@@ -6422,6 +6422,16 @@ const applyOnTwoProps = (propA, propB) => {
6422
6422
  };
6423
6423
  };
6424
6424
 
6425
+ // How much of the free space an expanding item claims next to its siblings:
6426
+ // expand={30} between four expand={18} is the wide one in the middle. A bare
6427
+ // `expand` claims one share, like everyone else asking for their part.
6428
+ const expandWeight = (value) => {
6429
+ if (value === true || value === "") {
6430
+ return 1;
6431
+ }
6432
+ return value;
6433
+ };
6434
+
6425
6435
  const LAYOUT_PROPS = {
6426
6436
  // all are handled by navi-attributes
6427
6437
  inline: () => {},
@@ -6529,7 +6539,7 @@ const DIMENSION_PROPS = {
6529
6539
  return { flexGrow: 1 };
6530
6540
  }
6531
6541
  // Parent is flex-x: grow as flex item
6532
- return { flexGrow: 1, flexBasis: "0%" };
6542
+ return { flexGrow: expandWeight(value), flexBasis: "0%" };
6533
6543
  }
6534
6544
  if (parentBoxFlow === "flex-y" || parentBoxFlow === "inline-flex-y") {
6535
6545
  return {
@@ -6553,7 +6563,7 @@ const DIMENSION_PROPS = {
6553
6563
  return { flexGrow: 1 };
6554
6564
  }
6555
6565
  // Parent is flex-y: grow as flex item
6556
- return { flexGrow: 1, flexBasis: "0%" };
6566
+ return { flexGrow: expandWeight(value), flexBasis: "0%" };
6557
6567
  }
6558
6568
  if (parentBoxFlow === "flex-x" || parentBoxFlow === "inline-flex-x") {
6559
6569
  return {
@@ -7834,6 +7844,11 @@ Object.assign(PSEUDO_CLASSES, {
7834
7844
  ":visited": {
7835
7845
  attribute: "data-visited",
7836
7846
  },
7847
+ // Written by whoever knows the current url — a Link from its href, a Button
7848
+ // from its route — so it lives here rather than with one of them.
7849
+ ":-navi-href-current": {
7850
+ attribute: "data-href-current",
7851
+ },
7837
7852
  });
7838
7853
  const definePseudoClass = (pseudoClass, definition) => {
7839
7854
  PSEUDO_CLASSES[pseudoClass] = definition;
@@ -16381,7 +16396,7 @@ installImportMetaCssBuild(import.meta);/**
16381
16396
  * - Arrow automatically shows when pointing at a valid anchor element
16382
16397
  * - Centers in viewport when no anchor element provided or anchor is too big
16383
16398
  */
16384
- const css$T = /* css */`
16399
+ const css$V = /* css */`
16385
16400
  @layer navi {
16386
16401
  .navi_callout {
16387
16402
  /* A callout is parented to what it explains, so it inherits from it — and
@@ -16601,7 +16616,7 @@ const openCallout = (message, {
16601
16616
  skipFocus = false,
16602
16617
  debug = () => {}
16603
16618
  } = {}) => {
16604
- import.meta.css = [css$T, "@jsenv/navi/src/control/rules/callout/callout.js"];
16619
+ import.meta.css = [css$V, "@jsenv/navi/src/control/rules/callout/callout.js"];
16605
16620
  if (debug === true) {
16606
16621
  debug = (e, ...args) => console.debug(`"${e.type}" -> `, ...args);
16607
16622
  }
@@ -28374,7 +28389,7 @@ const setupNetworkMonitoring = () => {
28374
28389
  };
28375
28390
  setupNetworkMonitoring();
28376
28391
 
28377
- installImportMetaCssBuild(import.meta);const css$S = /* css */`
28392
+ installImportMetaCssBuild(import.meta);const css$U = /* css */`
28378
28393
  .navi_loading_indicator_fluid_container {
28379
28394
  position: relative;
28380
28395
  display: flex;
@@ -28406,7 +28421,7 @@ const LoadingIndicatorFluid = ({
28406
28421
  visuallyHidden,
28407
28422
  ...rest
28408
28423
  }) => {
28409
- import.meta.css = [css$S, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
28424
+ import.meta.css = [css$U, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
28410
28425
  const ref = useRef(null);
28411
28426
  // The container dimensions can be deduced from the ref itself as the indicator is absolute inset 0
28412
28427
  const [containerWidth, setContainerWidth] = useState(0);
@@ -28611,7 +28626,7 @@ const LoadingRectangleSvg = ({
28611
28626
  });
28612
28627
  };
28613
28628
 
28614
- installImportMetaCssBuild(import.meta);const css$R = /* css */`
28629
+ installImportMetaCssBuild(import.meta);const css$T = /* css */`
28615
28630
  .navi_loading_outline_wrapper {
28616
28631
  position: absolute;
28617
28632
  /* Controls place the outline slightly outside their box, right on top of
@@ -28648,7 +28663,7 @@ installImportMetaCssBuild(import.meta);const css$R = /* css */`
28648
28663
  }
28649
28664
  `;
28650
28665
  const LoadingOutline = props => {
28651
- import.meta.css = [css$R, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
28666
+ import.meta.css = [css$T, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
28652
28667
  if (props.containerRef) {
28653
28668
  const container = props.containerRef.current;
28654
28669
  if (!container) {
@@ -28873,7 +28888,7 @@ const selectByTextStrings = (element, range, startText, endText) => {
28873
28888
  };
28874
28889
 
28875
28890
  installImportMetaCssBuild(import.meta);// https://jsfiddle.net/v5xzJ/4/
28876
- const css$Q = /* css */`
28891
+ const css$S = /* css */`
28877
28892
  @layer navi {
28878
28893
  .navi_text {
28879
28894
  &[data-skeleton] {
@@ -29378,7 +29393,7 @@ const TextShrinkWrap = props => {
29378
29393
  });
29379
29394
  };
29380
29395
  const TextUI = props => {
29381
- import.meta.css = [css$Q, "@jsenv/navi/src/text/text.jsx"];
29396
+ import.meta.css = [css$S, "@jsenv/navi/src/text/text.jsx"];
29382
29397
  let {
29383
29398
  ref,
29384
29399
  spacing,
@@ -29529,7 +29544,7 @@ const TextWithSelectRange = ({
29529
29544
  };
29530
29545
 
29531
29546
  installImportMetaCssBuild(import.meta);// # TextAnchor — how it works
29532
- const css$P = /* css */`
29547
+ const css$R = /* css */`
29533
29548
  .navi_text_anchor {
29534
29549
  vertical-align: baseline;
29535
29550
  user-select: none;
@@ -29564,7 +29579,7 @@ const TextAnchor = ({
29564
29579
  textSize,
29565
29580
  lineLayout
29566
29581
  }) => {
29567
- import.meta.css = [css$P, "@jsenv/navi/src/text/text_anchor.jsx"];
29582
+ import.meta.css = [css$R, "@jsenv/navi/src/text/text_anchor.jsx"];
29568
29583
  const anchorRef = useRef();
29569
29584
 
29570
29585
  // Plain useLayoutEffect would also fire while an ancestor dialog/popover
@@ -29679,7 +29694,7 @@ const computeTopOffset = ({
29679
29694
  };
29680
29695
  const charTopCanvas = document.createElement("canvas");
29681
29696
 
29682
- installImportMetaCssBuild(import.meta);const css$O = /* css */`
29697
+ installImportMetaCssBuild(import.meta);const css$Q = /* css */`
29683
29698
  @layer navi {
29684
29699
  /* Ensure data attributes from box.jsx can win to update display */
29685
29700
  .navi_icon {
@@ -29837,7 +29852,7 @@ const Icon = ({
29837
29852
  fillLine,
29838
29853
  ...props
29839
29854
  }) => {
29840
- import.meta.css = [css$O, "@jsenv/navi/src/text/icon.jsx"];
29855
+ import.meta.css = [css$Q, "@jsenv/navi/src/text/icon.jsx"];
29841
29856
  const innerChildren = href ? jsx("svg", {
29842
29857
  width: "100%",
29843
29858
  height: "100%",
@@ -29941,6 +29956,18 @@ const useIsVisited = (url) => {
29941
29956
  }, [url, visitedUrlsSignal.value]);
29942
29957
  };
29943
29958
 
29959
+ /**
29960
+ * What a binder tab hands to whatever it contains: a way to say "I am the
29961
+ * current one". A `Link` inside a tab knows it is current (its route matches)
29962
+ * long before the binder could work it out, so it reports instead of the
29963
+ * binder guessing — see `Link`'s use of this context.
29964
+ *
29965
+ * Its own module so `link.jsx` can read it without importing the binder.
29966
+ *
29967
+ * @type {import("preact").Context<{ reportCurrent: (current: boolean) => void } | null>}
29968
+ */
29969
+ const BinderItemContext = createContext(null);
29970
+
29944
29971
  /*
29945
29972
  * Custom hook to apply semi-transparent color when an element should be dimmed.
29946
29973
  *
@@ -29978,7 +30005,7 @@ const useDimColorWhen = (elementRef, shouldDim) => {
29978
30005
  });
29979
30006
  };
29980
30007
 
29981
- installImportMetaCssBuild(import.meta);const css$N = /* css */`
30008
+ installImportMetaCssBuild(import.meta);const css$P = /* css */`
29982
30009
  @layer navi {
29983
30010
  .navi_link {
29984
30011
  --link-border-radius: unset;
@@ -30053,7 +30080,12 @@ installImportMetaCssBuild(import.meta);const css$N = /* css */`
30053
30080
  aspect-ratio: inherit;
30054
30081
  padding-top: var(--x-link-padding-top);
30055
30082
  padding-right: var(--x-link-padding-right);
30056
- padding-bottom: var(--link-loading-outline-size);
30083
+ /* The loading underline is drawn inside the box: its own room is kept at
30084
+ the bottom on top of whatever padding was asked for, so a link that
30085
+ starts loading never reflows. */
30086
+ padding-bottom: calc(
30087
+ var(--x-link-padding-bottom) + var(--link-loading-outline-size)
30088
+ );
30057
30089
  padding-left: var(--x-link-padding-left);
30058
30090
  color: var(--x-link-color);
30059
30091
  text-decoration: var(--x-link-text-decoration);
@@ -30311,6 +30343,21 @@ const LinkStyleCSSVars = {
30311
30343
  color: "--link-color-selected"
30312
30344
  }
30313
30345
  };
30346
+ // A link placed inside a binder tab tells that tab whether it is the current
30347
+ // one. The binder cannot know: which page an url opens is the link's business,
30348
+ // and it is settled before the binder renders anything.
30349
+ const useReportCurrentToBinderItem = current => {
30350
+ const reportCurrent = useContext(BinderItemContext);
30351
+ useLayoutEffect(() => {
30352
+ if (!reportCurrent) {
30353
+ return undefined;
30354
+ }
30355
+ reportCurrent(current);
30356
+ return () => {
30357
+ reportCurrent(false);
30358
+ };
30359
+ }, [reportCurrent, current]);
30360
+ };
30314
30361
  const LinkPseudoClasses = [":hover", ":active", ":-navi-pressed", ":focus", ":focus-visible", ":read-only", ":disabled", ":visited", ":-navi-loading", ":-navi-href-internal", ":-navi-href-external", ":-navi-href-anchor", ":-navi-href-current", ":-navi-selected"];
30315
30362
  const LinkPseudoElements = ["::-navi-loader", "::-navi-indicator"];
30316
30363
  Object.assign(PSEUDO_CLASSES, {
@@ -30322,9 +30369,6 @@ Object.assign(PSEUDO_CLASSES, {
30322
30369
  },
30323
30370
  ":-navi-href-anchor": {
30324
30371
  attribute: "data-href-anchor"
30325
- },
30326
- ":-navi-href-current": {
30327
- attribute: "data-href-current"
30328
30372
  }
30329
30373
  });
30330
30374
 
@@ -30400,7 +30444,7 @@ Object.assign(PSEUDO_CLASSES, {
30400
30444
  * @param {boolean} [props.readOnly]
30401
30445
  */
30402
30446
  const Link = props => {
30403
- import.meta.css = [css$N, "@jsenv/navi/src/nav/link/link.jsx"];
30447
+ import.meta.css = [css$P, "@jsenv/navi/src/nav/link/link.jsx"];
30404
30448
  if (props.route) {
30405
30449
  return jsx(LinkWithRoute, {
30406
30450
  ...props
@@ -30489,6 +30533,7 @@ const LinkPlain = props => {
30489
30533
  isCurrent
30490
30534
  } = getHrefTargetInfo(href);
30491
30535
  const innerCurrent = current || isCurrent;
30536
+ useReportCurrentToBinderItem(innerCurrent);
30492
30537
  controlHostProps.basePseudoState = {
30493
30538
  ...basePseudoState,
30494
30539
  ":visited": visited,
@@ -30634,7 +30679,7 @@ installImportMetaCssBuild(import.meta);/**
30634
30679
  * TabList component with support for horizontal and vertical layouts
30635
30680
  * https://dribbble.com/search/tabs
30636
30681
  */
30637
- const css$M = /* css */`
30682
+ const css$O = /* css */`
30638
30683
  @layer navi {
30639
30684
  .navi_nav {
30640
30685
  --nav-border: none;
@@ -30727,20 +30772,60 @@ const css$M = /* css */`
30727
30772
  }
30728
30773
  }
30729
30774
 
30730
- &[data-panel-border-connection] {
30731
- --nav-border-width: 10px;
30775
+ /* Folder tabs: the current tab and the panel share one surface. Every tab
30776
+ carries the panel's line on the side facing it, and the current one
30777
+ paints that side with the panel background instead, so the line opens
30778
+ there. The negative margin makes the two lines overlap — without it the
30779
+ panel's own line would still be drawn under the tabs; and since a tab
30780
+ background is painted under its border too, a tab with a background of
30781
+ its own would otherwise hide the panel's line over its width.
30782
+
30783
+ The line is 1px and never thicker: past that, the corner where a square
30784
+ tab meets the panel shows the miter as a visible notch. */
30785
+ &[data-panel-position] {
30786
+ --nav-border-color: transparent;
30787
+ --nav-tab-border-radius: 0px;
30788
+
30789
+ --x-nav-panel-background: var(
30790
+ --nav-panel-background,
30791
+ var(--link-background-current, white)
30792
+ );
30793
+
30732
30794
  position: relative;
30733
30795
  z-index: 1;
30734
30796
 
30735
30797
  .navi_link {
30736
- border: var(--nav-border-width) solid transparent;
30798
+ border: 1px solid transparent;
30799
+
30800
+ &[data-href-current] {
30801
+ border-color: var(--nav-border-color);
30802
+ }
30803
+ }
30804
+
30805
+ &[data-panel-position="after"] {
30806
+ margin-bottom: -1px;
30737
30807
 
30738
- &[data-tab-selected] {
30739
- border-color: gray;
30740
- border-bottom-color: var(--nav-background);
30808
+ .navi_link {
30809
+ border-bottom-color: var(--nav-border-color);
30741
30810
 
30742
- border-top-left-radius: 5px;
30743
- border-top-right-radius: 5px;
30811
+ &[data-href-current] {
30812
+ border-bottom-color: var(--x-nav-panel-background);
30813
+ border-top-left-radius: var(--nav-tab-border-radius);
30814
+ border-top-right-radius: var(--nav-tab-border-radius);
30815
+ }
30816
+ }
30817
+ }
30818
+ &[data-panel-position="before"] {
30819
+ margin-top: -1px;
30820
+
30821
+ .navi_link {
30822
+ border-top-color: var(--nav-border-color);
30823
+
30824
+ &[data-href-current] {
30825
+ border-top-color: var(--x-nav-panel-background);
30826
+ border-bottom-right-radius: var(--nav-tab-border-radius);
30827
+ border-bottom-left-radius: var(--nav-tab-border-radius);
30828
+ }
30744
30829
  }
30745
30830
  }
30746
30831
  }
@@ -30766,11 +30851,10 @@ const Nav = ({
30766
30851
  expandX,
30767
30852
  linkBorderRadiusInherit,
30768
30853
  panelPosition,
30769
- // before or after
30770
- panelBorderConnection,
30854
+ // "before" or "after": which side the panel sits on, turning the nav into folder tabs
30771
30855
  ...props
30772
30856
  }) => {
30773
- import.meta.css = [css$M, "@jsenv/navi/src/nav/link/nav.jsx"];
30857
+ import.meta.css = [css$O, "@jsenv/navi/src/nav/link/nav.jsx"];
30774
30858
  children = toChildArray(children);
30775
30859
  return jsx(Box, {
30776
30860
  as: "nav",
@@ -30781,7 +30865,6 @@ const Nav = ({
30781
30865
  "data-expand": expand || expandX ? "" : undefined,
30782
30866
  "data-vertical": vertical ? "" : undefined,
30783
30867
  "data-panel-position": panelPosition,
30784
- "data-panel-border-connection": panelBorderConnection ? "" : undefined,
30785
30868
  expand: expand,
30786
30869
  expandX: expandX,
30787
30870
  spacing: spacing,
@@ -30794,6 +30877,1285 @@ const Nav = ({
30794
30877
  });
30795
30878
  };
30796
30879
 
30880
+ /**
30881
+ * The binder outline: one closed path wrapping the tab row AND the page, with
30882
+ * the current tab opening into the page through concave junction curves.
30883
+ *
30884
+ * Why a path instead of CSS borders — CSS cannot do either of these:
30885
+ * 1. Omit one side of a border while keeping the surrounding corners intact
30886
+ * (the current tab has no border on the page side, so it merges with it).
30887
+ * 2. Curve a corner inward (concave), which is what a tab-to-page junction
30888
+ * is. `border-radius` only bulges outward.
30889
+ *
30890
+ * Everything here is in "path space": the main axis (along the tab row) is x
30891
+ * and the cross axis goes from the tabs toward the page, whatever the real
30892
+ * orientation. Tabs left/right reuse the top/bottom builders and transpose the
30893
+ * result at the end.
30894
+ *
30895
+ * The path is a centerline: it runs half a border-width inside the box edges,
30896
+ * so a stroke of `borderWidth` covers exactly the border area the CSS borders
30897
+ * of the inactive tabs occupy, and the two line up.
30898
+ */
30899
+
30900
+ // Low-level arc helpers: produce SVG arc commands with an explicit sweep flag.
30901
+ // sweep=1 → clockwise arc; sweep=0 → counter-clockwise arc.
30902
+ const arcSweep1 = (r, dx, dy) => {
30903
+ if (r <= 0) {
30904
+ return `l ${dx},${dy}`;
30905
+ }
30906
+ return `a ${r},${r} 0 0 1 ${dx},${dy}`;
30907
+ };
30908
+ const arcSweep0 = (r, dx, dy) => {
30909
+ if (r <= 0) {
30910
+ return `l ${dx},${dy}`;
30911
+ }
30912
+ return `a ${r},${r} 0 0 0 ${dx},${dy}`;
30913
+ };
30914
+ // Semantic aliases used when drawing the CW outer path.
30915
+ // In a CW path: convex (outward) corners use sweep=1, concave (inward) use sweep=0.
30916
+ const arcConvex = arcSweep1;
30917
+ const arcConcave = arcSweep0;
30918
+
30919
+ // Swaps x and y in a command list produced by the path builders (only
30920
+ // M/h/v/l/a/Z commands, in the exact format they emit). Swapping axes is a
30921
+ // mirror transform, so arc sweep flags must flip.
30922
+ const transposePath = (commands) =>
30923
+ commands.map((command) => {
30924
+ const parts = command.split(" ");
30925
+ const type = parts[0];
30926
+ if (type === "M" || type === "l") {
30927
+ const [x, y] = parts[1].split(",");
30928
+ return `${type} ${y},${x}`;
30929
+ }
30930
+ if (type === "h") {
30931
+ return `v ${parts[1]}`;
30932
+ }
30933
+ if (type === "v") {
30934
+ return `h ${parts[1]}`;
30935
+ }
30936
+ if (type === "a") {
30937
+ const sweep = parts[4] === "1" ? "0" : "1";
30938
+ const [dx, dy] = parts[5].split(",");
30939
+ return `a ${parts[1]} ${parts[2]} ${parts[3]} ${sweep} ${dy},${dx}`;
30940
+ }
30941
+ return command;
30942
+ });
30943
+
30944
+ /**
30945
+ * @param {object} params
30946
+ * @param {number} params.tabX - Where the current tab starts along the tab row.
30947
+ * @param {number} params.tabWidth - Its size along the tab row.
30948
+ * @param {number} params.tabRowHeight - From the outer edge of the tab row to
30949
+ * the junction line where tabs meet the page.
30950
+ * @param {number} params.panelWidth - The whole binder, along the tab row.
30951
+ * @param {number} params.panelHeight - From the junction line to the far edge
30952
+ * of the page.
30953
+ * @param {number} params.gapBeforeTabs - Free room before the first tab, and
30954
+ * @param {number} params.gapAfterTabs - after the last one: a page corner on
30955
+ * the tab-row side is only rounded when its gap has room for the radius.
30956
+ * @param {number} params.outerRadius - The corners of the shape as a whole:
30957
+ * the page's, and the ones the tab row shares with it. Square when the
30958
+ * binder fills its container, rounded when it sits in a rounded one.
30959
+ * @param {number} params.tabRadius - The tabs' own corners, away from the
30960
+ * page, and the concave curves where a tab opens into it. Independent: tabs
30961
+ * can be rounded inside a square binder, and the other way round.
30962
+ * @param {"top"|"bottom"|"left"|"right"} params.tabsPosition
30963
+ * @returns {string} An SVG path `d` attribute.
30964
+ */
30965
+ const buildBinderPath = ({
30966
+ tabX,
30967
+ tabWidth,
30968
+ tabRowHeight,
30969
+ panelWidth,
30970
+ panelHeight,
30971
+ borderWidth,
30972
+ outerRadius,
30973
+ tabRadius,
30974
+ gapBeforeTabs,
30975
+ gapAfterTabs,
30976
+ tabsPosition,
30977
+ }) => {
30978
+ const h = borderWidth / 2;
30979
+ const withoutBorder = (radius) => (radius - h < 0 ? 0 : radius - h);
30980
+ const r = withoutBorder(tabRadius);
30981
+ const rOuter = withoutBorder(outerRadius);
30982
+ // Page corners on the tab-row side only get rounded when the gap before /
30983
+ // after the tabs leaves room for the full radius.
30984
+ const gapAfter = gapAfterTabs < 0 ? 0 : gapAfterTabs;
30985
+ const rTR = gapAfter >= rOuter ? rOuter : 0;
30986
+ const gapBefore = gapBeforeTabs < 0 ? 0 : gapBeforeTabs;
30987
+ const rTL = gapBefore >= rOuter ? rOuter : 0;
30988
+
30989
+ const params = {
30990
+ tabX,
30991
+ tabWidth,
30992
+ tabRowHeight,
30993
+ panelWidth,
30994
+ panelHeight,
30995
+ h,
30996
+ r,
30997
+ rOuter,
30998
+ rTR,
30999
+ rTL,
31000
+ };
31001
+ const d =
31002
+ tabsPosition === "top" || tabsPosition === "left"
31003
+ ? buildBinderCenterlinePathAbove(params)
31004
+ : buildBinderCenterlinePathBelow(params);
31005
+ const commands =
31006
+ tabsPosition === "left" || tabsPosition === "right" ? transposePath(d) : d;
31007
+ return commands.join(" ");
31008
+ };
31009
+
31010
+ // Builds the centerline path when tabs are ABOVE the panel.
31011
+ // CW path: M(tabLeft+r, top) → tab top → tab TR → [right junction] → panel TR
31012
+ // → panel right ↓ → panel BR → panel bottom ← → panel BL → panel left ↑ → [left junction] → tab TL → Z
31013
+ const buildBinderCenterlinePathAbove = ({
31014
+ tabX,
31015
+ tabWidth,
31016
+ tabRowHeight,
31017
+ panelWidth,
31018
+ panelHeight,
31019
+ h,
31020
+ r,
31021
+ rOuter,
31022
+ rTR,
31023
+ rTL,
31024
+ }) => {
31025
+ const hasRightJunction = tabX + tabWidth < panelWidth;
31026
+ const hasLeftJunction = tabX > 0;
31027
+
31028
+ const left = h;
31029
+ const top = h;
31030
+ const right = panelWidth - h;
31031
+ const bottom = tabRowHeight + panelHeight - h;
31032
+ const tabLeft = tabX + h;
31033
+ const tabRight = tabX + tabWidth - h;
31034
+ const junctionY = tabRowHeight - h;
31035
+
31036
+ const rVSpace = junctionY - top - r;
31037
+ const rTotalHSpace = right - tabRight;
31038
+ const rFits = hasRightJunction && rVSpace >= r && rTotalHSpace >= r + rTR;
31039
+
31040
+ const lVSpace = junctionY - top - r;
31041
+ const lTotalHSpace = tabLeft - left - rTL; // capped by gap left of first tab
31042
+ // lFits: room for panel TL arc (rTL) + concave junction arc (r)
31043
+ const lFits = hasLeftJunction && lVSpace >= r && lTotalHSpace >= r;
31044
+ // lTight: only room for panel TL arc, skip concave — favor panel rounding
31045
+ const lTight = hasLeftJunction && lVSpace >= r && lTotalHSpace >= 0;
31046
+
31047
+ const d = [];
31048
+ d.push(`M ${tabLeft + r},${top}`);
31049
+ d.push(`h ${tabRight - r - (tabLeft + r)}`);
31050
+
31051
+ // ── Right side ────────────────────────────────────────────────────────────
31052
+ if (!hasRightJunction) {
31053
+ d.push(arcConvex(r, r, r));
31054
+ d.push(`v ${bottom - top - r - rOuter}`);
31055
+ } else if (rFits) {
31056
+ d.push(arcConvex(r, r, r)); // tab TR: right→down
31057
+ d.push(`v ${rVSpace - r}`); // tab right ↓ to concave arc start
31058
+ d.push(arcConcave(r, r, r)); // junction concave: down→right
31059
+ d.push(`h ${rTotalHSpace - r - rTR}`); // panel top →
31060
+ d.push(arcConvex(rTR, rTR, rTR)); // panel TR: right→down
31061
+ d.push(`v ${bottom - junctionY - rTR - rOuter}`); // panel right ↓
31062
+ } else {
31063
+ d.push(arcConvex(r, r, r)); // tab TR: right→down
31064
+ d.push(`v ${rVSpace}`); // straight down to junctionY
31065
+ d.push(`h ${rTotalHSpace - rTR}`); // panel top →
31066
+ d.push(arcConvex(rTR, rTR, rTR)); // panel TR: right→down (clamped)
31067
+ d.push(`v ${bottom - junctionY - rTR - rOuter}`); // panel right ↓
31068
+ }
31069
+
31070
+ // ── Panel BR → bottom → BL ────────────────────────────────────────────────
31071
+ d.push(arcConvex(rOuter, -rOuter, rOuter));
31072
+ d.push(`h -${right - left - 2 * rOuter}`);
31073
+ d.push(arcConvex(rOuter, -rOuter, -rOuter));
31074
+
31075
+ // ── Panel left ↑ + left junction back to M ───────────────────────────────
31076
+ if (!hasLeftJunction) {
31077
+ d.push(`v -${bottom - top - rOuter - r}`);
31078
+ d.push(arcConvex(r, r, -r)); // panel TL = tab TL: up→right
31079
+ } else if (lFits) {
31080
+ d.push(`v -${bottom - junctionY - rOuter - rTL}`); // panel left ↑ to (left, junctionY+rTL)
31081
+ d.push(arcConvex(rTL, rTL, -rTL)); // panel TL: up→right
31082
+ d.push(`h ${lTotalHSpace - r}`); // panel top → to (tabLeft-r, junctionY)
31083
+ d.push(arcConcave(r, r, -r)); // junction concave: right→up
31084
+ d.push(`v -${lVSpace - r}`); // tab left ↑
31085
+ d.push(arcConvex(r, r, -r)); // tab TL: up→right
31086
+ } else if (lTight) {
31087
+ // Panel TL rounded, no concave — square tab corner
31088
+ d.push(`v -${bottom - junctionY - rOuter - rTL}`); // panel left ↑ to (left, junctionY+rTL)
31089
+ d.push(arcConvex(rTL, rTL, -rTL)); // panel TL: up→right
31090
+ d.push(`h ${lTotalHSpace}`); // panel top → to (tabLeft, junctionY)
31091
+ d.push(`v -${lVSpace}`); // tab left ↑
31092
+ d.push(arcConvex(r, r, -r)); // tab TL: up→right
31093
+ } else {
31094
+ d.push(`v -${bottom - junctionY - rOuter}`); // panel left ↑ to junctionY
31095
+ d.push(`h ${lTotalHSpace}`); // panel top →
31096
+ d.push(`v -${lVSpace}`); // tab left ↑
31097
+ d.push(arcConvex(r, r, -r)); // tab TL: up→right
31098
+ }
31099
+
31100
+ d.push("Z");
31101
+ return d;
31102
+ };
31103
+
31104
+ // Builds the centerline path when tabs are BELOW the panel.
31105
+ // CW path: M(left+r, top) → panel top → panel TR → panel right ↓ → [right junction] →
31106
+ // tab right ↓ → tab BR → tab bottom ← → [left junction] → tab BL → tab left ↑ → panel left ↑ → panel TL → Z
31107
+ //
31108
+ // Right junction: panel right ↓ to junctionY (square corner), left along panel bottom gap,
31109
+ // concave notch arc (left→down), tab right ↓.
31110
+ // Left junction: tab left ↑, concave notch arc (up→left), left along panel bottom gap to panel left
31111
+ // (square corner), panel left ↑.
31112
+ const buildBinderCenterlinePathBelow = ({
31113
+ tabX,
31114
+ tabWidth,
31115
+ tabRowHeight,
31116
+ panelWidth,
31117
+ panelHeight,
31118
+ h,
31119
+ r,
31120
+ rOuter,
31121
+ rTR,
31122
+ rTL,
31123
+ }) => {
31124
+ const hasRightJunction = tabX + tabWidth < panelWidth;
31125
+ const hasLeftJunction = tabX > 0;
31126
+
31127
+ const left = h;
31128
+ const top = h;
31129
+ const right = panelWidth - h;
31130
+ const bottom = panelHeight + tabRowHeight - h;
31131
+ const tabLeft = tabX + h;
31132
+ const tabRight = tabX + tabWidth - h;
31133
+ const junctionY = panelHeight - h;
31134
+
31135
+ // Vertical room on the page's right/left sides, between its top corner
31136
+ // (outer) and the junction.
31137
+ const rPanelVSpace = junctionY - top - rOuter;
31138
+
31139
+ // Right junction: the page corner beside the last tab uses rTR.
31140
+ const rTotalHSpace = right - tabRight - rTR;
31141
+ // rFits: room for the page corner arc (rTR) + the concave junction arc (r)
31142
+ const rFits = hasRightJunction && rPanelVSpace >= r && rTotalHSpace >= r;
31143
+ // rTight: only room for the page corner arc, skip concave — favor the page
31144
+ const rTight = hasRightJunction && rPanelVSpace >= r && rTotalHSpace >= 0;
31145
+
31146
+ // Left junction: the page corner beside the first tab uses rTL.
31147
+ const lTotalHSpace = tabLeft - left - rTL;
31148
+ const lFits = hasLeftJunction && rPanelVSpace >= r && lTotalHSpace >= r;
31149
+ const lTight = hasLeftJunction && rPanelVSpace >= r && lTotalHSpace >= 0;
31150
+
31151
+ const d = [];
31152
+
31153
+ // M: start after the page's TL arc, going right along the page top
31154
+ d.push(`M ${left + rOuter},${top}`);
31155
+ d.push(`h ${right - left - 2 * rOuter}`); // page top →
31156
+
31157
+ // ── Right side: page right ↓ then (junction or direct) tab right ↓ ──────
31158
+ if (!hasRightJunction) {
31159
+ d.push(arcConvex(rOuter, rOuter, rOuter)); // page TR: right→down
31160
+ d.push(`v ${bottom - top - rOuter - r}`); // full right side ↓
31161
+ d.push(arcConvex(r, -r, r)); // tab BR: down→left
31162
+ } else if (rFits) {
31163
+ d.push(arcConvex(rOuter, rOuter, rOuter)); // page TR: right→down
31164
+ d.push(`v ${rPanelVSpace - rTR}`); // page right ↓ to (right, junctionY-rTR)
31165
+ d.push(arcConvex(rTR, -rTR, rTR)); // page corner: down→left
31166
+ d.push(`h -${rTotalHSpace - r}`); // page bottom ← to (tabRight+r, junctionY)
31167
+ d.push(arcConcave(r, -r, r)); // junction concave: left→down
31168
+ d.push(`v ${bottom - junctionY - 2 * r}`); // tab right ↓ to (tabRight, bottom-r)
31169
+ d.push(arcConvex(r, -r, r)); // tab BR: down→left
31170
+ } else if (rTight) {
31171
+ // Page corner rounded, no concave — square tab corner
31172
+ d.push(arcConvex(rOuter, rOuter, rOuter)); // page TR: right→down
31173
+ d.push(`v ${rPanelVSpace - rTR}`); // page right ↓ to (right, junctionY-rTR)
31174
+ d.push(arcConvex(rTR, -rTR, rTR)); // page corner: down→left
31175
+ d.push(`h -${rTotalHSpace}`); // page bottom ← to (tabRight, junctionY)
31176
+ d.push(`v ${bottom - junctionY - r}`); // tab right ↓ to (tabRight, bottom-r)
31177
+ d.push(arcConvex(r, -r, r)); // tab BR: down→left
31178
+ } else {
31179
+ // Very tight: all square corners
31180
+ d.push(arcConvex(rOuter, rOuter, rOuter)); // page TR: right→down
31181
+ d.push(`v ${rPanelVSpace}`); // page right ↓ to junctionY
31182
+ d.push(`h -${right - tabRight}`); // page bottom ← to tabRight
31183
+ d.push(`v ${bottom - junctionY - r}`); // tab right ↓ to (tabRight, bottom-r)
31184
+ d.push(arcConvex(r, -r, r)); // tab BR: down→left
31185
+ }
31186
+
31187
+ // ── Tab bottom ← ──────────────────────────────────────────────────────────
31188
+ d.push(`h -${tabRight - r - (tabLeft + r)}`); // tab bottom ←
31189
+
31190
+ // ── Left side: tab BL → tab left ↑ → (junction or direct) → page left ↑ ──
31191
+ if (!hasLeftJunction) {
31192
+ d.push(arcConvex(r, -r, -r)); // tab BL: left→up
31193
+ d.push(`v -${bottom - top - rOuter - r}`); // full left side ↑
31194
+ d.push(arcConvex(rOuter, rOuter, -rOuter)); // page TL: up→right → M
31195
+ } else if (lFits) {
31196
+ d.push(arcConvex(r, -r, -r)); // tab BL: left→up
31197
+ d.push(`v -${bottom - junctionY - 2 * r}`); // tab left ↑ to (tabLeft, junctionY+r)
31198
+ d.push(arcConcave(r, -r, -r)); // junction concave: up→left
31199
+ d.push(`h -${lTotalHSpace - r}`); // page bottom ← to (left+rTL, junctionY)
31200
+ d.push(arcConvex(rTL, -rTL, -rTL)); // page corner: left→up
31201
+ d.push(`v -${rPanelVSpace - rTL}`); // page left ↑ to (left, top+rOuter)
31202
+ d.push(arcConvex(rOuter, rOuter, -rOuter)); // page TL: up→right → M
31203
+ } else if (lTight) {
31204
+ // Page corner rounded, no concave — square tab corner
31205
+ d.push(arcConvex(r, -r, -r)); // tab BL: left→up
31206
+ d.push(`v -${bottom - junctionY - r}`); // tab left ↑ to (tabLeft, junctionY)
31207
+ d.push(`h -${lTotalHSpace}`); // page bottom ← to (left+rTL, junctionY)
31208
+ d.push(arcConvex(rTL, -rTL, -rTL)); // page corner: left→up
31209
+ d.push(`v -${rPanelVSpace - rTL}`); // page left ↑ to (left, top+rOuter)
31210
+ d.push(arcConvex(rOuter, rOuter, -rOuter)); // page TL: up→right → M
31211
+ } else {
31212
+ // Very tight: all square corners
31213
+ d.push(arcConvex(r, -r, -r)); // tab BL: left→up
31214
+ d.push(`v -${bottom - junctionY - r}`); // tab left ↑ to junctionY
31215
+ d.push(`h -${tabLeft - left}`); // page bottom ← to left
31216
+ d.push(`v -${rPanelVSpace}`); // page left ↑ to (left, top+rOuter)
31217
+ d.push(arcConvex(rOuter, rOuter, -rOuter)); // page TL: up→right → M
31218
+ }
31219
+
31220
+ d.push("Z");
31221
+ return d;
31222
+ };
31223
+
31224
+ installImportMetaCssBuild(import.meta);/**
31225
+ * A binder: tabs and the page they open, drawn as one shape.
31226
+ *
31227
+ * Unlike a `Nav`, a binder owns its content — the tab and the page it shows are
31228
+ * the same item, declared together:
31229
+ *
31230
+ * <Binder>
31231
+ * <Binder.Item value="a" label="Approved">…the page…</Binder.Item>
31232
+ * </Binder>
31233
+ *
31234
+ * Layers, and why they are what they are:
31235
+ * - The outline (tab row + page, with the concave junctions around the current
31236
+ * tab) is a single SVG path — see binder_path.js for why CSS borders cannot
31237
+ * draw it. It covers the whole binder and sits above the inactive tabs but
31238
+ * below the current tab and the page.
31239
+ * - The inactive tabs keep plain CSS borders: the path only runs along the
31240
+ * current tab, so the top of an inactive tab is its own to draw. Their border
31241
+ * width matches the path's stroke and lands in the same band, which is what
31242
+ * makes the two read as one continuous line.
31243
+ * - Every tab reserves that band on the page side with a margin, so the stroke
31244
+ * never lands on a tab's own edge.
31245
+ *
31246
+ * Border width participates in layout (it is added to the tab and page
31247
+ * padding): a thick border grows the binder rather than eating into the text.
31248
+ */
31249
+ const css$N = /* css */`
31250
+ @layer navi {
31251
+ .navi_binder {
31252
+ --binder-border-width: var(--navi-control-border-width);
31253
+ --binder-border-radius: var(--navi-control-border-radius);
31254
+ /* The tabs' own corners. Their own knob because the two answer different
31255
+ questions: the outer radius follows the container the binder sits in
31256
+ (square when it fills the page), the tab radius is the tabs' shape. */
31257
+ --binder-tab-border-radius: var(--binder-border-radius);
31258
+ --binder-border-color: var(--navi-control-border-color);
31259
+ --binder-background: var(--navi-surface-color);
31260
+ /* The tabs that are not open read as the same paper, slightly shaded:
31261
+ derived from the border color so one override themes both, in light
31262
+ and dark alike. */
31263
+ --binder-tab-background: color-mix(
31264
+ in srgb,
31265
+ var(--binder-border-color) 12%,
31266
+ var(--binder-background)
31267
+ );
31268
+ --binder-tab-border-color: var(--binder-border-color);
31269
+ --binder-tab-color: var(--navi-color-secondary);
31270
+ --binder-tab-background-hover: color-mix(
31271
+ in srgb,
31272
+ var(--binder-border-color) 22%,
31273
+ var(--binder-background)
31274
+ );
31275
+ --binder-padding-x: var(--navi-s);
31276
+ --binder-padding-y: var(--navi-xs);
31277
+ --binder-page-padding: var(--navi-m);
31278
+ }
31279
+ }
31280
+
31281
+ .navi_binder {
31282
+ position: relative;
31283
+ display: flex;
31284
+ width: fit-content;
31285
+ align-items: stretch;
31286
+
31287
+ &[data-tabs-position="top"] {
31288
+ flex-direction: column;
31289
+ }
31290
+ &[data-tabs-position="bottom"] {
31291
+ flex-direction: column-reverse;
31292
+ }
31293
+ &[data-tabs-position="left"] {
31294
+ flex-direction: row;
31295
+ }
31296
+ &[data-tabs-position="right"] {
31297
+ flex-direction: row-reverse;
31298
+ }
31299
+ }
31300
+
31301
+ .navi_binder_outline {
31302
+ position: absolute;
31303
+ inset: 0;
31304
+ z-index: 1;
31305
+ pointer-events: none;
31306
+ overflow: visible;
31307
+ }
31308
+
31309
+ .navi_binder_tabs {
31310
+ display: flex;
31311
+ /* The page decides how wide the binder is; the tab row only fills it.
31312
+ Sizing it to 0 keeps its labels out of the binder's intrinsic size (a
31313
+ long one would otherwise stretch the whole thing) while min-size brings
31314
+ it back to the width the page settled on — which is what leaves the
31315
+ labels something to truncate against. */
31316
+ .navi_binder[data-tabs-position="top"] &,
31317
+ .navi_binder[data-tabs-position="bottom"] & {
31318
+ width: 0;
31319
+ min-width: 100%;
31320
+ }
31321
+ .navi_binder[data-tabs-position="left"] &,
31322
+ .navi_binder[data-tabs-position="right"] & {
31323
+ height: 0;
31324
+ min-height: 100%;
31325
+ }
31326
+ /* Tabs of unequal size line up on the page side, so the junction band is
31327
+ one straight line whatever their content. */
31328
+ .navi_binder[data-tabs-position="top"] & {
31329
+ align-items: flex-end;
31330
+ }
31331
+ .navi_binder[data-tabs-position="bottom"] & {
31332
+ align-items: flex-start;
31333
+ }
31334
+ /* A column of tabs is as wide as its widest one, all of them that wide, so
31335
+ the junction band is one straight line here too. */
31336
+ .navi_binder[data-tabs-position="left"] &,
31337
+ .navi_binder[data-tabs-position="right"] & {
31338
+ flex-direction: column;
31339
+ align-items: stretch;
31340
+ }
31341
+ }
31342
+
31343
+ .navi_binder_tab {
31344
+ position: relative;
31345
+ z-index: 0;
31346
+ /* A flex item refuses to go under its content unless told to; without this
31347
+ a long label widens the tab row instead of truncating. */
31348
+ min-width: 0;
31349
+ min-height: 0;
31350
+ /* The border is part of the tab's box on every tab, current one included
31351
+ (transparent there — the path draws it), so opening a tab never moves
31352
+ anything. */
31353
+ padding: var(--binder-padding-y) var(--binder-padding-x);
31354
+ flex: 0 1 auto;
31355
+ color: var(--binder-tab-color);
31356
+ font: inherit;
31357
+ text-align: center;
31358
+ text-decoration: none;
31359
+ background: var(--binder-tab-background);
31360
+ border: var(--binder-border-width) solid var(--binder-tab-border-color);
31361
+ cursor: pointer;
31362
+ /* "stretch" is an equal share each, whatever a tab holds — a basis read
31363
+ from the content would hand the longest label the widest tab. */
31364
+ .navi_binder[data-tabs-align="stretch"] & {
31365
+ flex: 1 1 0;
31366
+ }
31367
+
31368
+ /* The navi attributes rather than the CSS pseudo-classes: they are what a
31369
+ demo can hold with pseudoState={{ ":focus-visible": true }}. */
31370
+ &[data-hover] {
31371
+ background: var(--binder-tab-background-hover);
31372
+ }
31373
+ /* The ring would be cut by the neighbours and by the outline band around
31374
+ it, so it is drawn inside and lifted above them. */
31375
+ &[data-focus-visible] {
31376
+ z-index: 3;
31377
+ outline: var(--navi-focus-outline-width) solid
31378
+ var(--navi-focus-outline-color);
31379
+ outline-offset: calc(-1 * var(--navi-focus-outline-width));
31380
+ }
31381
+ &[data-current] {
31382
+ z-index: 2;
31383
+ color: inherit;
31384
+ background: transparent;
31385
+ border-color: transparent;
31386
+ cursor: default;
31387
+ }
31388
+ /* Adjacent tabs share one line rather than stacking two borders. */
31389
+ &:not(:first-child) {
31390
+ margin-inline-start: calc(-1 * var(--binder-border-width));
31391
+ }
31392
+
31393
+ /* The margin on the page side is the junction band the path draws in; the
31394
+ corners away from the page are the ones that round. */
31395
+ .navi_binder[data-tabs-position="top"] & {
31396
+ margin-bottom: var(--binder-border-width);
31397
+ border-bottom: none;
31398
+ border-radius: var(--binder-tab-border-radius)
31399
+ var(--binder-tab-border-radius) 0 0;
31400
+ }
31401
+ .navi_binder[data-tabs-position="bottom"] & {
31402
+ margin-top: var(--binder-border-width);
31403
+ border-top: none;
31404
+ border-radius: 0 0 var(--binder-tab-border-radius)
31405
+ var(--binder-tab-border-radius);
31406
+ }
31407
+ .navi_binder[data-tabs-position="left"] & {
31408
+ margin-right: var(--binder-border-width);
31409
+ border-right: none;
31410
+ border-radius: var(--binder-tab-border-radius) 0 0
31411
+ var(--binder-tab-border-radius);
31412
+ }
31413
+ .navi_binder[data-tabs-position="right"] & {
31414
+ margin-left: var(--binder-border-width);
31415
+ border-left: none;
31416
+ border-radius: 0 var(--binder-tab-border-radius)
31417
+ var(--binder-tab-border-radius) 0;
31418
+ }
31419
+ /* A column of tabs shares its main axis with the block direction, where
31420
+ margin-inline-start would collapse the wrong pair. */
31421
+ .navi_binder[data-tabs-position="left"] &:not(:first-child),
31422
+ .navi_binder[data-tabs-position="right"] &:not(:first-child) {
31423
+ margin-inline-start: 0;
31424
+ margin-top: calc(-1 * var(--binder-border-width));
31425
+ }
31426
+ }
31427
+
31428
+ /* A tab that grows with its label would push the binder wider than the page
31429
+ it opens; by default a label stays on one line and is cut with an ellipsis.
31430
+ maxLines={n} lets it use n lines, maxLines={false} lets it wrap freely. */
31431
+ .navi_binder_tab_label {
31432
+ display: block;
31433
+
31434
+ &[data-max-lines="1"] {
31435
+ text-overflow: ellipsis;
31436
+ white-space: nowrap;
31437
+ overflow: hidden;
31438
+ }
31439
+ &[data-max-lines]:not([data-max-lines="1"]) {
31440
+ display: -webkit-box;
31441
+ overflow: hidden;
31442
+ -webkit-box-orient: vertical;
31443
+ -webkit-line-clamp: var(--binder-tab-max-lines);
31444
+ }
31445
+ }
31446
+
31447
+ .navi_binder_page {
31448
+ position: relative;
31449
+ z-index: 2;
31450
+ /* Same reservation as the tabs, on the three sides the path runs along. */
31451
+ padding: calc(var(--binder-page-padding) + var(--binder-border-width));
31452
+ flex: 1;
31453
+ color: inherit;
31454
+
31455
+ /* An app shell: the binder is the window, the page is what scrolls in it,
31456
+ and the tab row stays put without being fixed — it is simply the part of
31457
+ the binder that does not scroll. min-size:0 because a flex item refuses
31458
+ to shrink under its content, which is what makes overflow do nothing. */
31459
+ .navi_binder[data-scrollable-page] & {
31460
+ min-width: 0;
31461
+ min-height: 0;
31462
+ overflow: auto;
31463
+ }
31464
+
31465
+ .navi_binder[data-tabs-position="top"] & {
31466
+ padding-top: var(--binder-page-padding);
31467
+ }
31468
+ .navi_binder[data-tabs-position="bottom"] & {
31469
+ padding-bottom: var(--binder-page-padding);
31470
+ }
31471
+ .navi_binder[data-tabs-position="left"] & {
31472
+ padding-left: var(--binder-page-padding);
31473
+ }
31474
+ .navi_binder[data-tabs-position="right"] & {
31475
+ padding-right: var(--binder-page-padding);
31476
+ }
31477
+ }
31478
+ `;
31479
+ const BinderStyleCSSVars = {
31480
+ borderWidth: "--binder-border-width",
31481
+ borderRadius: "--binder-border-radius",
31482
+ tabBorderRadius: "--binder-tab-border-radius",
31483
+ borderColor: "--binder-border-color",
31484
+ background: "--binder-background",
31485
+ tabBackground: "--binder-tab-background",
31486
+ tabBackgroundHover: "--binder-tab-background-hover",
31487
+ tabBorderColor: "--binder-tab-border-color",
31488
+ tabColor: "--binder-tab-color",
31489
+ paddingX: "--binder-padding-x",
31490
+ paddingY: "--binder-padding-y",
31491
+ pagePadding: "--binder-page-padding"
31492
+ };
31493
+ const TABS_ALIGN_TO_JUSTIFY_CONTENT = {
31494
+ start: "flex-start",
31495
+ center: "center",
31496
+ end: "flex-end",
31497
+ stretch: "stretch"
31498
+ };
31499
+
31500
+ /**
31501
+ * @type {import("ignore:preact").FunctionComponent<{
31502
+ * value?: any,
31503
+ * defaultValue?: any,
31504
+ * onChange?: (value: any) => void,
31505
+ * tabsPosition?: "top"|"bottom"|"left"|"right",
31506
+ * tabsAlign?: "start"|"center"|"end"|"stretch",
31507
+ * borderWidth?: string|number,
31508
+ * borderRadius?: string|number,
31509
+ * tabBorderRadius?: string|number,
31510
+ * borderColor?: string,
31511
+ * background?: string,
31512
+ * tabBackground?: string,
31513
+ * tabBackgroundHover?: string,
31514
+ * tabBorderColor?: string,
31515
+ * tabColor?: string,
31516
+ * paddingX?: string|number,
31517
+ * paddingY?: string|number,
31518
+ * pagePadding?: string|number,
31519
+ * }>}
31520
+ * @param {any} [props.value] - The open item, when the caller keeps that state.
31521
+ * Leave it out for a binder that keeps its own (see `defaultValue`).
31522
+ * @param {any} [props.defaultValue] - Which item is open to begin with;
31523
+ * defaults to the first one.
31524
+ * @param {"top"|"bottom"|"left"|"right"} [props.tabsPosition="top"] - Which
31525
+ * side of the page the tabs sit on.
31526
+ * @param {"start"|"center"|"end"|"stretch"} [props.tabsAlign="stretch"] - How
31527
+ * the tabs spread along that side.
31528
+ * @param {boolean} [props.scrollablePage] - Makes the page scroll inside the
31529
+ * binder instead of growing it. What an app shell wants: give the binder the
31530
+ * size of the window and the tab row stays in place while the page scrolls
31531
+ * under it — no `position: fixed` anywhere, so the tabs and the page are
31532
+ * still one shape and the junction still merges them. The trade is that the
31533
+ * document itself no longer scrolls.
31534
+ * @param {number|false} [props.maxLines=1] - How many lines a tab label may
31535
+ * use before being cut with an ellipsis; `false` lets it wrap freely. A tab
31536
+ * is never allowed to widen the binder past the page it opens, so the
31537
+ * default keeps every label on one line. Overridable per item.
31538
+ * @param {string|number} [props.borderWidth] - Thickness of the whole outline,
31539
+ * the inactive tabs included. It is added to the paddings, so a thick border
31540
+ * grows the binder instead of closing in on the text.
31541
+ * @param {string} [props.tabBorderColor] - The inactive tabs alone; the rest of
31542
+ * the outline follows `borderColor`.
31543
+ * @param {import("ignore:preact").ComponentChildren} props.children - `Binder.Item`s.
31544
+ */
31545
+ const Binder = ({
31546
+ children,
31547
+ value,
31548
+ defaultValue,
31549
+ onChange,
31550
+ tabsPosition = "top",
31551
+ tabsAlign = "stretch",
31552
+ maxLines = 1,
31553
+ scrollablePage,
31554
+ borderWidth,
31555
+ borderRadius,
31556
+ tabBorderRadius,
31557
+ paddingX,
31558
+ paddingY,
31559
+ pagePadding,
31560
+ ...props
31561
+ }) => {
31562
+ import.meta.css = [css$N, "@jsenv/navi/src/nav/binder/binder.jsx"];
31563
+ const items = toChildArray(children).map((child, index) => {
31564
+ const {
31565
+ value: itemValue,
31566
+ label,
31567
+ children: content,
31568
+ ...tabProps
31569
+ } = child.props;
31570
+ return {
31571
+ value: itemValue === undefined ? index : itemValue,
31572
+ label,
31573
+ content,
31574
+ // Everything else belongs to the tab: an item is how one styles and
31575
+ // configures the control that opens it.
31576
+ tabProps
31577
+ };
31578
+ });
31579
+ const [valueOwnState, setValueOwnState] = useState(defaultValue === undefined ? items[0]?.value : defaultValue);
31580
+ // A tab holding a Link knows it is the current one (its route matches) before
31581
+ // the binder could work it out, and says so — see BinderItemContext.
31582
+ const [reportedValue, setReportedValue] = useState(undefined);
31583
+ let wantedValue;
31584
+ if (value !== undefined) {
31585
+ wantedValue = value;
31586
+ } else if (reportedValue !== undefined) {
31587
+ wantedValue = reportedValue;
31588
+ } else {
31589
+ wantedValue = valueOwnState;
31590
+ }
31591
+ const currentIndex = items.findIndex(item => item.value === wantedValue);
31592
+ const currentItem = currentIndex === -1 ? items[0] : items[currentIndex];
31593
+ const currentValue = currentItem?.value;
31594
+ const open = item => {
31595
+ if (value === undefined) {
31596
+ setValueOwnState(item.value);
31597
+ }
31598
+ onChange?.(item.value);
31599
+ };
31600
+ const reportCurrent = (item, current) => {
31601
+ setReportedValue(previous => {
31602
+ if (current) {
31603
+ return item.value;
31604
+ }
31605
+ // Only the tab that claimed it may withdraw it, otherwise the tabs that
31606
+ // just stopped being current would erase the one that just became it.
31607
+ return previous === item.value ? undefined : previous;
31608
+ });
31609
+ };
31610
+ const vertical = tabsPosition === "left" || tabsPosition === "right";
31611
+ const tabsFirst = tabsPosition === "top" || tabsPosition === "left";
31612
+ const rootRef = useRef(null);
31613
+ const tabRefs = useRef([]);
31614
+ const onKeyDown = event => {
31615
+ const {
31616
+ key
31617
+ } = event;
31618
+ const previousKey = vertical ? "ArrowUp" : "ArrowLeft";
31619
+ const nextKey = vertical ? "ArrowDown" : "ArrowRight";
31620
+ let nextIndex;
31621
+ if (key === previousKey) {
31622
+ nextIndex = currentIndex <= 0 ? items.length - 1 : currentIndex - 1;
31623
+ } else if (key === nextKey) {
31624
+ nextIndex = currentIndex >= items.length - 1 ? 0 : currentIndex + 1;
31625
+ } else if (key === "Home") {
31626
+ nextIndex = 0;
31627
+ } else if (key === "End") {
31628
+ nextIndex = items.length - 1;
31629
+ } else {
31630
+ return;
31631
+ }
31632
+ event.preventDefault();
31633
+ open(items[nextIndex]);
31634
+ tabRefs.current[nextIndex]?.focus();
31635
+ };
31636
+ return jsxs(Box, {
31637
+ ref: rootRef,
31638
+ baseClassName: "navi_binder",
31639
+ "data-tabs-position": tabsPosition,
31640
+ "data-tabs-align": tabsAlign,
31641
+ "data-scrollable-page": scrollablePage ? "" : undefined,
31642
+ borderWidth: withPixelUnit(borderWidth),
31643
+ borderRadius: withPixelUnit(borderRadius),
31644
+ tabBorderRadius: withPixelUnit(tabBorderRadius),
31645
+ paddingX: withPixelUnit(paddingX),
31646
+ paddingY: withPixelUnit(paddingY),
31647
+ pagePadding: withPixelUnit(pagePadding),
31648
+ ...props,
31649
+ styleCSSVars: BinderStyleCSSVars,
31650
+ children: [jsx(BinderOutline, {
31651
+ rootRef: rootRef,
31652
+ currentValue: currentValue,
31653
+ tabsPosition: tabsPosition,
31654
+ vertical: vertical,
31655
+ tabsFirst: tabsFirst
31656
+ }), jsx("div", {
31657
+ className: "navi_binder_tabs",
31658
+ role: "tablist",
31659
+ "aria-orientation": vertical ? "vertical" : "horizontal",
31660
+ style: {
31661
+ justifyContent: TABS_ALIGN_TO_JUSTIFY_CONTENT[tabsAlign]
31662
+ },
31663
+ children: items.map((item, index) => jsx(BinderTab, {
31664
+ item: item,
31665
+ index: index,
31666
+ tabRefs: tabRefs,
31667
+ current: item === currentItem,
31668
+ maxLines: maxLines,
31669
+ onOpen: () => open(item),
31670
+ onKeyDown: onKeyDown,
31671
+ onReportCurrent: current => reportCurrent(item, current)
31672
+ }, item.value))
31673
+ }), jsx("div", {
31674
+ className: "navi_binder_page",
31675
+ role: "tabpanel",
31676
+ children: currentItem?.content
31677
+ })]
31678
+ });
31679
+ };
31680
+
31681
+ /**
31682
+ * @param {any} [props.value] - What identifies this item; defaults to its
31683
+ * position among its siblings.
31684
+ * @param {import("ignore:preact").ComponentChildren} [props.label] - What the tab
31685
+ * shows.
31686
+ * @param {import("../route.js").Route} [props.route] - Makes the tab a `Link`
31687
+ * to that route, and the binder follows the url: the open item is the one
31688
+ * whose route matches, no `value`/`onChange` needed.
31689
+ * @param {string} [props.href] - Same, for a plain url.
31690
+ * @param {import("ignore:preact").ComponentChildren} [props.children] - The page.
31691
+ *
31692
+ * Every other prop goes to the tab (a `Box`, or a `Link` when routed): an item
31693
+ * styles and configures the control that opens it.
31694
+ */
31695
+ // Read by Binder, never rendered on its own: an item declares a tab AND its
31696
+ // page, and the binder needs both before it renders either.
31697
+ const BinderItem = () => null;
31698
+ Binder.Item = BinderItem;
31699
+ const BINDER_TAB_PSEUDO_CLASSES = [":hover", ":active", ":focus-visible"];
31700
+ const BinderTab = ({
31701
+ item,
31702
+ index,
31703
+ tabRefs,
31704
+ current,
31705
+ maxLines,
31706
+ onOpen,
31707
+ onKeyDown,
31708
+ onReportCurrent
31709
+ }) => {
31710
+ const {
31711
+ label,
31712
+ tabProps
31713
+ } = item;
31714
+ const {
31715
+ maxLines: itemMaxLines = maxLines,
31716
+ onClick,
31717
+ ...rest
31718
+ } = tabProps;
31719
+ return jsx(Box, {
31720
+ as: "button",
31721
+ type: "button",
31722
+ baseClassName: "navi_binder_tab",
31723
+ pseudoClasses: BINDER_TAB_PSEUDO_CLASSES,
31724
+ ref: element => {
31725
+ tabRefs.current[index] = element;
31726
+ },
31727
+ role: "tab",
31728
+ "aria-selected": current,
31729
+ tabIndex: current ? 0 : -1,
31730
+ "data-current": current ? "" : undefined,
31731
+ onClick: event => {
31732
+ onOpen();
31733
+ onClick?.(event);
31734
+ },
31735
+ onKeyDown: onKeyDown,
31736
+ ...rest,
31737
+ children: jsx(BinderItemContext.Provider, {
31738
+ value: onReportCurrent,
31739
+ children: jsx("span", {
31740
+ className: "navi_binder_tab_label",
31741
+ "data-max-lines": itemMaxLines || undefined,
31742
+ style: itemMaxLines > 1 ? {
31743
+ "--binder-tab-max-lines": itemMaxLines
31744
+ } : undefined,
31745
+ children: label
31746
+ })
31747
+ })
31748
+ });
31749
+ };
31750
+ const BinderOutline = ({
31751
+ rootRef,
31752
+ currentValue,
31753
+ tabsPosition,
31754
+ vertical,
31755
+ tabsFirst
31756
+ }) => {
31757
+ const [drawing, setDrawing] = useState(null);
31758
+ const measure = useCallback(() => {
31759
+ setDrawing(measureDrawing({
31760
+ rootEl: rootRef.current,
31761
+ vertical,
31762
+ tabsFirst
31763
+ }));
31764
+ }, [rootRef, vertical, tabsFirst]);
31765
+ useLayoutEffect(() => {
31766
+ const rootEl = rootRef.current;
31767
+ // The outline is positioned absolutely, so drawing it cannot change any
31768
+ // observed size — the observer settles after one pass.
31769
+ const resizeObserver = new ResizeObserver(measure);
31770
+ resizeObserver.observe(rootEl);
31771
+ for (const tabEl of rootEl.querySelectorAll(".navi_binder_tab")) {
31772
+ resizeObserver.observe(tabEl);
31773
+ }
31774
+ measure();
31775
+ return () => resizeObserver.disconnect();
31776
+ }, [rootRef, measure, currentValue]);
31777
+ if (!drawing) {
31778
+ return jsx("svg", {
31779
+ className: "navi_binder_outline",
31780
+ "aria-hidden": "true"
31781
+ });
31782
+ }
31783
+ return jsx("svg", {
31784
+ className: "navi_binder_outline",
31785
+ "aria-hidden": "true",
31786
+ width: drawing.width,
31787
+ height: drawing.height,
31788
+ children: jsx("path", {
31789
+ d: buildBinderPath({
31790
+ ...drawing.pathParams,
31791
+ tabsPosition
31792
+ }),
31793
+ fill: drawing.background,
31794
+ stroke: drawing.borderColor,
31795
+ "stroke-width": drawing.borderWidth
31796
+ })
31797
+ });
31798
+ };
31799
+
31800
+ // Everything the path needs, read off the DOM rather than taken from props:
31801
+ // the shape has to follow the sizes the layout actually produced — text length,
31802
+ // font, whatever the page contains.
31803
+ const measureDrawing = ({
31804
+ rootEl,
31805
+ vertical,
31806
+ tabsFirst
31807
+ }) => {
31808
+ const tabEls = rootEl.querySelectorAll(".navi_binder_tab");
31809
+ const currentTabEl = rootEl.querySelector(".navi_binder_tab[data-current]");
31810
+ if (!currentTabEl) {
31811
+ return null;
31812
+ }
31813
+ const rootComputedStyle = getComputedStyle(rootEl);
31814
+ const readVar = name => rootComputedStyle.getPropertyValue(name).trim();
31815
+ const borderWidth = parseFloat(readVar("--binder-border-width")) || 0;
31816
+ const outerRadius = parseFloat(readVar("--binder-border-radius")) || 0;
31817
+ const tabRadius = parseFloat(readVar("--binder-tab-border-radius")) || 0;
31818
+
31819
+ // Path space: the main axis runs along the tab row, the cross axis goes from
31820
+ // the tabs toward the page.
31821
+ const mainSizeOf = el => vertical ? el.offsetHeight : el.offsetWidth;
31822
+ const mainStartOf = el => {
31823
+ let start = vertical ? el.offsetTop : el.offsetLeft;
31824
+ let offsetParent = el.offsetParent;
31825
+ while (offsetParent && offsetParent !== rootEl) {
31826
+ start += vertical ? offsetParent.offsetTop : offsetParent.offsetLeft;
31827
+ offsetParent = offsetParent.offsetParent;
31828
+ }
31829
+ return start;
31830
+ };
31831
+ const crossStartOf = el => {
31832
+ let start = vertical ? el.offsetLeft : el.offsetTop;
31833
+ let offsetParent = el.offsetParent;
31834
+ while (offsetParent && offsetParent !== rootEl) {
31835
+ start += vertical ? offsetParent.offsetLeft : offsetParent.offsetTop;
31836
+ offsetParent = offsetParent.offsetParent;
31837
+ }
31838
+ return start;
31839
+ };
31840
+ const crossSizeOf = el => vertical ? el.offsetWidth : el.offsetHeight;
31841
+ const mainSize = vertical ? rootEl.offsetHeight : rootEl.offsetWidth;
31842
+ const crossSize = vertical ? rootEl.offsetWidth : rootEl.offsetHeight;
31843
+ const currentTabCrossStart = crossStartOf(currentTabEl);
31844
+
31845
+ // The junction line lands in the margin every tab keeps on the page side —
31846
+ // right against the tab boxes, so it continues the CSS borders drawn inside
31847
+ // them instead of covering their last row of pixels.
31848
+ let tabRowHeight;
31849
+ let panelHeight;
31850
+ if (tabsFirst) {
31851
+ tabRowHeight = currentTabCrossStart + crossSizeOf(currentTabEl) + borderWidth;
31852
+ panelHeight = crossSize - tabRowHeight;
31853
+ } else {
31854
+ panelHeight = currentTabCrossStart;
31855
+ tabRowHeight = crossSize - panelHeight;
31856
+ }
31857
+ const firstTabEl = tabEls[0];
31858
+ const lastTabEl = tabEls[tabEls.length - 1];
31859
+ return {
31860
+ width: vertical ? crossSize : mainSize,
31861
+ height: vertical ? mainSize : crossSize,
31862
+ borderWidth,
31863
+ borderColor: readVar("--binder-border-color"),
31864
+ background: readVar("--binder-background"),
31865
+ pathParams: {
31866
+ tabX: mainStartOf(currentTabEl),
31867
+ tabWidth: mainSizeOf(currentTabEl),
31868
+ tabRowHeight,
31869
+ panelWidth: mainSize,
31870
+ panelHeight,
31871
+ borderWidth,
31872
+ outerRadius,
31873
+ tabRadius,
31874
+ gapBeforeTabs: mainStartOf(firstTabEl),
31875
+ gapAfterTabs: mainSize - (mainStartOf(lastTabEl) + mainSizeOf(lastTabEl))
31876
+ }
31877
+ };
31878
+ };
31879
+
31880
+ // A bare number in a CSS var stays a bare number and the declaration is
31881
+ // dropped; every length prop here is in pixels when it is not spelled out.
31882
+ const withPixelUnit = value => {
31883
+ if (typeof value === "number") {
31884
+ return `${value}px`;
31885
+ }
31886
+ return value;
31887
+ };
31888
+
31889
+ /**
31890
+ * The room a fixed bar takes from the content, published so whatever scrolls
31891
+ * under it can give that room back.
31892
+ *
31893
+ * There are TWO rooms to give back, and forgetting the second one is the
31894
+ * classic bug:
31895
+ *
31896
+ * - **padding**, so the end of the content can be scrolled out from under the
31897
+ * bar. Without it the last screenful stays covered, unreachable.
31898
+ * - **scroll-padding**, so anything the browser scrolls TO lands in front of
31899
+ * the bar rather than under it. An anchor link, `scrollIntoView()`, a focused
31900
+ * field brought into view, restoring a scroll position — all of them align
31901
+ * the target with the edge of the scrollport, which is behind the bar. The
31902
+ * padding above does not help here: it moves the content, not the place the
31903
+ * browser scrolls the target to.
31904
+ *
31905
+ * Published on <html> as CSS variables rather than applied to some element:
31906
+ * which element scrolls is the app's business, and an app with more than one
31907
+ * would have to fight a component that picked for it. The app either marks its
31908
+ * scrolling area with `data-navi-fixed-bar-space` (the rules below) or reads
31909
+ * the variables itself. `:root` gets the scroll-padding unconditionally,
31910
+ * because the document is the scrollport in the common case and an anchor
31911
+ * landing under a bar is never what anyone wants.
31912
+ *
31913
+ * The variables hold the bar's measured size — see the comment where FixedBar
31914
+ * sets them.
31915
+ */
31916
+
31917
+ const FIXED_BAR_SPACE_CSS = /* css */ `
31918
+ :root {
31919
+ --navi-fixed-bar-space-top: 0px;
31920
+ --navi-fixed-bar-space-bottom: 0px;
31921
+ --navi-fixed-bar-space-left: 0px;
31922
+ --navi-fixed-bar-space-right: 0px;
31923
+
31924
+ scroll-padding-top: var(--navi-fixed-bar-space-top);
31925
+ scroll-padding-right: var(--navi-fixed-bar-space-right);
31926
+ scroll-padding-bottom: var(--navi-fixed-bar-space-bottom);
31927
+ scroll-padding-left: var(--navi-fixed-bar-space-left);
31928
+ }
31929
+
31930
+ /* Put this on whatever scrolls under the bars. */
31931
+ [data-navi-fixed-bar-space] {
31932
+ padding-top: var(--navi-fixed-bar-space-top);
31933
+ padding-right: var(--navi-fixed-bar-space-right);
31934
+ padding-bottom: var(--navi-fixed-bar-space-bottom);
31935
+ padding-left: var(--navi-fixed-bar-space-left);
31936
+
31937
+ scroll-padding-top: var(--navi-fixed-bar-space-top);
31938
+ scroll-padding-right: var(--navi-fixed-bar-space-right);
31939
+ scroll-padding-bottom: var(--navi-fixed-bar-space-bottom);
31940
+ scroll-padding-left: var(--navi-fixed-bar-space-left);
31941
+ }
31942
+ `;
31943
+
31944
+ /**
31945
+ * @param {"top"|"bottom"|"left"|"right"} area
31946
+ * @param {string|null} value - `null` gives the room back to the content.
31947
+ */
31948
+ const setFixedBarSpace = (area, value) => {
31949
+ const property = `--navi-fixed-bar-space-${area}`;
31950
+ const { style } = document.documentElement;
31951
+ if (value === null) {
31952
+ style.removeProperty(property);
31953
+ } else {
31954
+ style.setProperty(property, value);
31955
+ }
31956
+ };
31957
+
31958
+ installImportMetaCssBuild(import.meta);/**
31959
+ * A strip pinned to one edge of the window, and the space it takes back from
31960
+ * the content. What goes in it is none of its business — a nav, a title, a
31961
+ * toolbar, an action.
31962
+ *
31963
+ * The four things it is here to get right, each of which is a trap:
31964
+ *
31965
+ * 1. **`position: fixed`, never sticky.** A sticky element sticks to its
31966
+ * nearest scrolling ancestor, and an app shell almost always has one (an
31967
+ * `overflow` somewhere) — the bar would then stick inside that box and
31968
+ * never to the window. Fixed, centered and bounded by `maxWidth`, it also
31969
+ * stays lined up with the content on a wide screen.
31970
+ * 2. **It gives its space back.** Being fixed it covers the content: without a
31971
+ * reserve the end of a long page stays under it, unreachable. It publishes
31972
+ * what it takes on <html> — see fixed_bar_space.js.
31973
+ * 3. **It reaches under the notch.** `env(safe-area-inset-*)` is padding, so
31974
+ * the content is pushed in while the background keeps running to the edge of
31975
+ * the screen. Across the bar that inset is added to `width`/`height` too, so
31976
+ * the size asked for is the size the content gets whatever the device does;
31977
+ * along the bar it adds to the padding asked for. Note that every
31978
+ * `env(safe-area-inset-*)` is 0 unless the page asks for it:
31979
+ * `<meta name="viewport" content="…, viewport-fit=cover">`.
31980
+ * 4. **Its hairline is a box-shadow, not a border.** A real border would eat
31981
+ * into the size; a box-shadow draws the identical line and stays out of
31982
+ * layout.
31983
+ */
31984
+ const css$M = /* css */`
31985
+ @layer navi {
31986
+ :root {
31987
+ --navi-fixed-bar-width: 56px;
31988
+ --navi-fixed-bar-height: 56px;
31989
+ --navi-fixed-bar-max-width: none;
31990
+ --navi-fixed-bar-background: var(--navi-surface-color);
31991
+ --navi-fixed-bar-border-width: 1px;
31992
+ --navi-fixed-bar-border-color: var(--navi-separator-color-default);
31993
+ /* Along the bar only — across it there is nothing to add, that direction
31994
+ is what the width/height prop names. None by default: a row of items
31995
+ sharing the whole strip is as common as a toolbar wanting air at its
31996
+ ends, and only the second can ask. */
31997
+ --navi-fixed-bar-padding: 0px;
31998
+ }
31999
+ }
32000
+
32001
+ ${FIXED_BAR_SPACE_CSS}
32002
+
32003
+ .navi_fixed_bar {
32004
+ position: fixed;
32005
+ z-index: 1;
32006
+ display: flex;
32007
+ box-sizing: border-box;
32008
+ margin: auto;
32009
+ align-items: center;
32010
+ background: var(--navi-fixed-bar-background);
32011
+
32012
+ /* Along the bar, the two insets that can bite into it there are added to
32013
+ the padding asked for: in landscape the notch is on a side, and a top bar
32014
+ whose padding ignored it would put its first item under it. */
32015
+ &[data-area="top"],
32016
+ &[data-area="bottom"] {
32017
+ right: 0;
32018
+ left: 0;
32019
+ /* No width of its own: pinned to both edges, the used width absorbs the
32020
+ padding instead of being inflated by it. max-width then narrows it and
32021
+ the auto margins re-center it. */
32022
+ max-width: var(--navi-fixed-bar-max-width);
32023
+ padding-right: calc(
32024
+ var(--navi-fixed-bar-padding) + env(safe-area-inset-right)
32025
+ );
32026
+ padding-left: calc(
32027
+ var(--navi-fixed-bar-padding) + env(safe-area-inset-left)
32028
+ );
32029
+ }
32030
+ &[data-area="left"],
32031
+ &[data-area="right"] {
32032
+ top: 0;
32033
+ bottom: 0;
32034
+ padding-top: calc(
32035
+ var(--navi-fixed-bar-padding) + env(safe-area-inset-top)
32036
+ );
32037
+ padding-bottom: calc(
32038
+ var(--navi-fixed-bar-padding) + env(safe-area-inset-bottom)
32039
+ );
32040
+ flex-direction: column;
32041
+ }
32042
+
32043
+ /* Across the bar, the inset of the edge it is pinned to is padding AND is
32044
+ added to the size: the background then runs under the notch while the
32045
+ content keeps the whole width/height asked for. */
32046
+ &[data-area="top"] {
32047
+ top: 0;
32048
+ height: calc(var(--navi-fixed-bar-height) + env(safe-area-inset-top));
32049
+ padding-top: env(safe-area-inset-top);
32050
+ box-shadow: 0 var(--navi-fixed-bar-border-width) 0
32051
+ var(--navi-fixed-bar-border-color);
32052
+ }
32053
+ &[data-area="bottom"] {
32054
+ bottom: 0;
32055
+ height: calc(var(--navi-fixed-bar-height) + env(safe-area-inset-bottom));
32056
+ padding-bottom: env(safe-area-inset-bottom);
32057
+ box-shadow: 0 calc(-1 * var(--navi-fixed-bar-border-width)) 0
32058
+ var(--navi-fixed-bar-border-color);
32059
+ }
32060
+ &[data-area="left"] {
32061
+ left: 0;
32062
+ width: calc(var(--navi-fixed-bar-width) + env(safe-area-inset-left));
32063
+ padding-left: env(safe-area-inset-left);
32064
+ box-shadow: var(--navi-fixed-bar-border-width) 0 0
32065
+ var(--navi-fixed-bar-border-color);
32066
+ }
32067
+ &[data-area="right"] {
32068
+ right: 0;
32069
+ width: calc(var(--navi-fixed-bar-width) + env(safe-area-inset-right));
32070
+ padding-right: env(safe-area-inset-right);
32071
+ box-shadow: calc(-1 * var(--navi-fixed-bar-border-width)) 0 0
32072
+ var(--navi-fixed-bar-border-color);
32073
+ }
32074
+ &[data-border="none"] {
32075
+ box-shadow: none;
32076
+ }
32077
+ }
32078
+ `;
32079
+ const FixedBarStyleCSSVars = {
32080
+ // width/height go through the vars the size calc reads: written as plain
32081
+ // inline styles they would land on the border box and the safe-area inset
32082
+ // would eat into the size asked for.
32083
+ width: "--navi-fixed-bar-width",
32084
+ height: "--navi-fixed-bar-height",
32085
+ maxWidth: "--navi-fixed-bar-max-width",
32086
+ padding: "--navi-fixed-bar-padding",
32087
+ background: "--navi-fixed-bar-background",
32088
+ borderWidth: "--navi-fixed-bar-border-width",
32089
+ borderColor: "--navi-fixed-bar-border-color"
32090
+ };
32091
+
32092
+ /**
32093
+ * @type {import("ignore:preact").FunctionComponent<{
32094
+ * area?: "top"|"bottom"|"left"|"right",
32095
+ * width?: string|number,
32096
+ * height?: string|number,
32097
+ * maxWidth?: string|number,
32098
+ * padding?: string|number,
32099
+ * background?: string,
32100
+ * border?: boolean,
32101
+ * borderWidth?: string|number,
32102
+ * borderColor?: string,
32103
+ * }>}
32104
+ * @param {"top"|"bottom"|"left"|"right"} [props.area="top"] - Which edge of
32105
+ * the window it is pinned to.
32106
+ * @param {string|number} [props.height] - For a bar on the top or bottom
32107
+ * @param {string|number} [props.width] - …and for one on a side. The safe-area
32108
+ * inset is NOT part of it: it is added on top, so the content keeps the size
32109
+ * asked for.
32110
+ * @param {boolean} [props.border=true] - The hairline on the content side.
32111
+ * Drawn with a box-shadow so it never eats into the size; give it a
32112
+ * `borderWidth`/`borderColor`, or `border={false}` for none.
32113
+ * @param {string|number} [props.maxWidth] - Keeps the bar lined up with a
32114
+ * content column narrower than the window (it stays centered).
32115
+ */
32116
+ const FixedBar = ({
32117
+ children,
32118
+ area = "top",
32119
+ border = true,
32120
+ ...props
32121
+ }) => {
32122
+ import.meta.css = [css$M, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
32123
+ const defaultRef = useRef();
32124
+ props.ref = props.ref || defaultRef;
32125
+ // Whichever of width/height crosses the edge the bar sits on is what the
32126
+ // content has to be given back — and the bar's border box already IS that:
32127
+ // the size it was given plus the inset of that edge. Measured rather than
32128
+ // rebuilt as a calc() expression, so a size coming from anywhere — a prop, a
32129
+ // theme variable, the content itself — is reserved just the same, and each
32130
+ // `env()` inset stays the browser's business alone.
32131
+ const vertical = area === "left" || area === "right";
32132
+ const {
32133
+ ref
32134
+ } = props;
32135
+ useLayoutEffect(() => {
32136
+ const barElement = ref.current;
32137
+ if (!barElement) {
32138
+ return undefined;
32139
+ }
32140
+ const {
32141
+ width,
32142
+ height
32143
+ } = barElement.getBoundingClientRect();
32144
+ setFixedBarSpace(area, `${vertical ? width : height}px`);
32145
+ return () => {
32146
+ setFixedBarSpace(area, null);
32147
+ };
32148
+ }, [area]);
32149
+ return jsx(Box, {
32150
+ baseClassName: "navi_fixed_bar",
32151
+ "data-area": area,
32152
+ "data-border": border ? undefined : "none",
32153
+ ...props,
32154
+ styleCSSVars: FixedBarStyleCSSVars,
32155
+ children: children
32156
+ });
32157
+ };
32158
+
30797
32159
  const detectMac = () => {
30798
32160
  // Modern way using User-Agent Client Hints API
30799
32161
  if (window.navigator.userAgentData) {
@@ -33887,6 +35249,7 @@ const ButtonWithRoute = props => {
33887
35249
  route,
33888
35250
  routeParams,
33889
35251
  children,
35252
+ pseudoState,
33890
35253
  ...rest
33891
35254
  } = props;
33892
35255
  const url = route.buildUrl(routeParams);
@@ -33895,9 +35258,15 @@ const ButtonWithRoute = props => {
33895
35258
  } = useRouteStatus(route);
33896
35259
  const paramsAreMatching = route.matchesParams(routeParams);
33897
35260
  const linkMatching = matching && paramsAreMatching;
35261
+
35262
+ // Merged into whatever the caller already holds: a button can be forced into
35263
+ // a state for a demo and still learn its own current-ness from its route.
33898
35264
  return jsx(Next, {
33899
35265
  href: url,
33900
- "data-href-current": linkMatching ? "" : undefined,
35266
+ pseudoState: {
35267
+ ...pseudoState,
35268
+ ":-navi-href-current": linkMatching
35269
+ },
33901
35270
  ...rest,
33902
35271
  children: children || route.buildRelativeUrl(routeParams)
33903
35272
  });
@@ -33939,6 +35308,11 @@ installImportMetaCssBuild(import.meta);const css$E = /* css */`
33939
35308
  black
33940
35309
  );
33941
35310
  --button-color-hover: var(--button-color);
35311
+ /* Current: the button points at the page one is already on. Nothing by
35312
+ default — it is a nav item that wants to say so, not every button. */
35313
+ --button-border-color-current: var(--button-border-color);
35314
+ --button-background-color-current: var(--button-background-color);
35315
+ --button-color-current: var(--button-color);
33942
35316
  /* Pressed */
33943
35317
  --button-border-color-pressed: color-mix(
33944
35318
  in srgb,
@@ -33982,6 +35356,13 @@ installImportMetaCssBuild(import.meta);const css$E = /* css */`
33982
35356
  .navi_button {
33983
35357
  --x-button-outline-offset: var(--button-outline-offset);
33984
35358
  --x-button-border-color: var(--button-border-color);
35359
+ /* The shorthand wins over the parts when it is given, which is what makes
35360
+ border="none" remove the line: the parts alone can only ever describe
35361
+ a border, never the absence of one. */
35362
+ --x-button-border: var(
35363
+ --button-border,
35364
+ var(--button-border-width) solid var(--x-button-border-color)
35365
+ );
33985
35366
  --x-button-background: var(--button-background);
33986
35367
  --x-button-background-color: var(--button-background-color);
33987
35368
  --x-button-color: var(--button-color);
@@ -34045,9 +35426,7 @@ installImportMetaCssBuild(import.meta);const css$E = /* css */`
34045
35426
  --x-button-background-color,
34046
35427
  var(--x-button-background)
34047
35428
  );
34048
- border-width: var(--button-border-width);
34049
- border-style: solid;
34050
- border-color: var(--x-button-border-color);
35429
+ border: var(--x-button-border);
34051
35430
  border-radius: inherit;
34052
35431
  outline-width: var(--button-outline-width);
34053
35432
  outline-color: var(--button-outline-color);
@@ -34068,6 +35447,12 @@ installImportMetaCssBuild(import.meta);const css$E = /* css */`
34068
35447
  }
34069
35448
  }
34070
35449
 
35450
+ /* Current */
35451
+ &[data-href-current] {
35452
+ --x-button-border-color: var(--button-border-color-current);
35453
+ --x-button-background-color: var(--button-background-color-current);
35454
+ --x-button-color: var(--button-color-current);
35455
+ }
34071
35456
  /* Hover */
34072
35457
  &[data-hover] {
34073
35458
  --x-button-border-color: var(--button-border-color-hover);
@@ -34214,6 +35599,21 @@ installImportMetaCssBuild(import.meta);const css$E = /* css */`
34214
35599
  --x-button-background-color: transparent;
34215
35600
  }
34216
35601
  }
35602
+ /* Last word on the shrink, over whatever the variant decided: the variant
35603
+ guesses from how the button is drawn, and that guess is wrong as soon as
35604
+ the content is a box of its own — a discrete button holding a filled
35605
+ pill reads as a button being pressed, not as its content flinching. */
35606
+ &[data-press-effect="scale"][data-pressed] {
35607
+ .navi_button_content {
35608
+ transform: scale(0.9);
35609
+ }
35610
+ }
35611
+ &[data-press-effect="none"][data-pressed] {
35612
+ .navi_button_content {
35613
+ transform: none;
35614
+ }
35615
+ }
35616
+
34217
35617
  &[data-icon] {
34218
35618
  --button-padding: 0;
34219
35619
  display: inline-flex;
@@ -34274,6 +35674,7 @@ const ButtonUI = props => {
34274
35674
  rel,
34275
35675
  // visual
34276
35676
  variant,
35677
+ pressEffect,
34277
35678
  icon,
34278
35679
  cta,
34279
35680
  spacing
@@ -34287,18 +35688,31 @@ const ButtonUI = props => {
34287
35688
  children
34288
35689
  } = buttonControlHostProps;
34289
35690
  const loading = basePseudoState[":-navi-loading"];
35691
+
35692
+ // subscribe to document url to re-render and re-compute getHrefTargetInfo
35693
+ useDocumentUrl();
34290
35694
  const isLink = href !== undefined;
34291
35695
  let as = "button";
34292
35696
  let innerTarget;
34293
35697
  let innerRel;
35698
+ let innerCurrent;
34294
35699
  if (isLink) {
34295
35700
  as = "a";
34296
35701
  const {
34297
- isSameSite
35702
+ isSameSite,
35703
+ isCurrent
34298
35704
  } = getHrefTargetInfo(href);
34299
35705
  innerTarget = target === undefined ? isSameSite ? undefined : "_blank" : target;
34300
35706
  innerRel = rel === undefined ? isSameSite ? undefined : "noopener noreferrer" : rel;
35707
+ innerCurrent = isCurrent;
34301
35708
  }
35709
+ // For a button that has only an href: nothing else knows it points at the
35710
+ // page one is on. A route says so through `pseudoState`, which Box lays over
35711
+ // this one.
35712
+ buttonControlHostProps.basePseudoState = {
35713
+ ...basePseudoState,
35714
+ ":-navi-href-current": innerCurrent
35715
+ };
34302
35716
  const visualSelector = ".navi_button_content";
34303
35717
  useAccentColorAttributes(ref, null, {
34304
35718
  elementSelector: visualSelector
@@ -34318,6 +35732,7 @@ const ButtonUI = props => {
34318
35732
  type: "button",
34319
35733
  spacing: undefined,
34320
35734
  cta: undefined,
35735
+ pressEffect: undefined,
34321
35736
  ref: ref,
34322
35737
  as: as,
34323
35738
  href: href,
@@ -34347,6 +35762,7 @@ const ButtonUI = props => {
34347
35762
  e.preventDefault();
34348
35763
  },
34349
35764
  "data-variant": variant,
35765
+ "data-press-effect": pressEffect,
34350
35766
  "data-icon": icon ? "" : undefined,
34351
35767
  "data-cta": cta ? "" : undefined,
34352
35768
  "data-callout-arrow-x": "center"
@@ -34408,6 +35824,11 @@ const ButtonStyleCSSVars = {
34408
35824
  ":-navi-pressed": {
34409
35825
  borderColor: "--button-border-color-pressed"
34410
35826
  },
35827
+ ":-navi-href-current": {
35828
+ backgroundColor: "--button-background-color-current",
35829
+ borderColor: "--button-border-color-current",
35830
+ color: "--button-color-current"
35831
+ },
34411
35832
  ":read-only": {
34412
35833
  backgroundColor: "--button-background-color-readonly",
34413
35834
  borderColor: "--button-border-color-readonly",
@@ -34419,7 +35840,7 @@ const ButtonStyleCSSVars = {
34419
35840
  color: "--button-color-disabled"
34420
35841
  }
34421
35842
  };
34422
- const ButtonPseudoClasses = [":hover", ":active", ":-navi-pressed", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
35843
+ const ButtonPseudoClasses = [":-navi-href-current", ":hover", ":active", ":-navi-pressed", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
34423
35844
  const ButtonPseudoElements = ["::-navi-loader"];
34424
35845
  const ButtonShadow = () => {
34425
35846
  return jsx("span", {
@@ -58976,5 +60397,5 @@ const UserSvg = () => jsx("svg", {
58976
60397
  })
58977
60398
  });
58978
60399
 
58979
- export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Box, Button, ButtonCopyToClipboard, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isToday, languagesSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage, windowWidthSignal };
60400
+ export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isToday, languagesSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage, windowWidthSignal };
58980
60401
  //# sourceMappingURL=jsenv_navi.js.map