@jsenv/navi 0.29.2 → 0.29.4

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
  }
@@ -16381,7 +16381,7 @@ installImportMetaCssBuild(import.meta);/**
16381
16381
  * - Arrow automatically shows when pointing at a valid anchor element
16382
16382
  * - Centers in viewport when no anchor element provided or anchor is too big
16383
16383
  */
16384
- const css$T = /* css */`
16384
+ const css$V = /* css */`
16385
16385
  @layer navi {
16386
16386
  .navi_callout {
16387
16387
  /* A callout is parented to what it explains, so it inherits from it — and
@@ -16601,7 +16601,7 @@ const openCallout = (message, {
16601
16601
  skipFocus = false,
16602
16602
  debug = () => {}
16603
16603
  } = {}) => {
16604
- import.meta.css = [css$T, "@jsenv/navi/src/control/rules/callout/callout.js"];
16604
+ import.meta.css = [css$V, "@jsenv/navi/src/control/rules/callout/callout.js"];
16605
16605
  if (debug === true) {
16606
16606
  debug = (e, ...args) => console.debug(`"${e.type}" -> `, ...args);
16607
16607
  }
@@ -28374,7 +28374,7 @@ const setupNetworkMonitoring = () => {
28374
28374
  };
28375
28375
  setupNetworkMonitoring();
28376
28376
 
28377
- installImportMetaCssBuild(import.meta);const css$S = /* css */`
28377
+ installImportMetaCssBuild(import.meta);const css$U = /* css */`
28378
28378
  .navi_loading_indicator_fluid_container {
28379
28379
  position: relative;
28380
28380
  display: flex;
@@ -28406,7 +28406,7 @@ const LoadingIndicatorFluid = ({
28406
28406
  visuallyHidden,
28407
28407
  ...rest
28408
28408
  }) => {
28409
- import.meta.css = [css$S, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
28409
+ import.meta.css = [css$U, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
28410
28410
  const ref = useRef(null);
28411
28411
  // The container dimensions can be deduced from the ref itself as the indicator is absolute inset 0
28412
28412
  const [containerWidth, setContainerWidth] = useState(0);
@@ -28611,7 +28611,7 @@ const LoadingRectangleSvg = ({
28611
28611
  });
28612
28612
  };
28613
28613
 
28614
- installImportMetaCssBuild(import.meta);const css$R = /* css */`
28614
+ installImportMetaCssBuild(import.meta);const css$T = /* css */`
28615
28615
  .navi_loading_outline_wrapper {
28616
28616
  position: absolute;
28617
28617
  /* Controls place the outline slightly outside their box, right on top of
@@ -28648,7 +28648,7 @@ installImportMetaCssBuild(import.meta);const css$R = /* css */`
28648
28648
  }
28649
28649
  `;
28650
28650
  const LoadingOutline = props => {
28651
- import.meta.css = [css$R, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
28651
+ import.meta.css = [css$T, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
28652
28652
  if (props.containerRef) {
28653
28653
  const container = props.containerRef.current;
28654
28654
  if (!container) {
@@ -28873,7 +28873,7 @@ const selectByTextStrings = (element, range, startText, endText) => {
28873
28873
  };
28874
28874
 
28875
28875
  installImportMetaCssBuild(import.meta);// https://jsfiddle.net/v5xzJ/4/
28876
- const css$Q = /* css */`
28876
+ const css$S = /* css */`
28877
28877
  @layer navi {
28878
28878
  .navi_text {
28879
28879
  &[data-skeleton] {
@@ -29378,7 +29378,7 @@ const TextShrinkWrap = props => {
29378
29378
  });
29379
29379
  };
29380
29380
  const TextUI = props => {
29381
- import.meta.css = [css$Q, "@jsenv/navi/src/text/text.jsx"];
29381
+ import.meta.css = [css$S, "@jsenv/navi/src/text/text.jsx"];
29382
29382
  let {
29383
29383
  ref,
29384
29384
  spacing,
@@ -29529,7 +29529,7 @@ const TextWithSelectRange = ({
29529
29529
  };
29530
29530
 
29531
29531
  installImportMetaCssBuild(import.meta);// # TextAnchor — how it works
29532
- const css$P = /* css */`
29532
+ const css$R = /* css */`
29533
29533
  .navi_text_anchor {
29534
29534
  vertical-align: baseline;
29535
29535
  user-select: none;
@@ -29564,7 +29564,7 @@ const TextAnchor = ({
29564
29564
  textSize,
29565
29565
  lineLayout
29566
29566
  }) => {
29567
- import.meta.css = [css$P, "@jsenv/navi/src/text/text_anchor.jsx"];
29567
+ import.meta.css = [css$R, "@jsenv/navi/src/text/text_anchor.jsx"];
29568
29568
  const anchorRef = useRef();
29569
29569
 
29570
29570
  // Plain useLayoutEffect would also fire while an ancestor dialog/popover
@@ -29679,7 +29679,7 @@ const computeTopOffset = ({
29679
29679
  };
29680
29680
  const charTopCanvas = document.createElement("canvas");
29681
29681
 
29682
- installImportMetaCssBuild(import.meta);const css$O = /* css */`
29682
+ installImportMetaCssBuild(import.meta);const css$Q = /* css */`
29683
29683
  @layer navi {
29684
29684
  /* Ensure data attributes from box.jsx can win to update display */
29685
29685
  .navi_icon {
@@ -29837,7 +29837,7 @@ const Icon = ({
29837
29837
  fillLine,
29838
29838
  ...props
29839
29839
  }) => {
29840
- import.meta.css = [css$O, "@jsenv/navi/src/text/icon.jsx"];
29840
+ import.meta.css = [css$Q, "@jsenv/navi/src/text/icon.jsx"];
29841
29841
  const innerChildren = href ? jsx("svg", {
29842
29842
  width: "100%",
29843
29843
  height: "100%",
@@ -29941,6 +29941,18 @@ const useIsVisited = (url) => {
29941
29941
  }, [url, visitedUrlsSignal.value]);
29942
29942
  };
29943
29943
 
29944
+ /**
29945
+ * What a binder tab hands to whatever it contains: a way to say "I am the
29946
+ * current one". A `Link` inside a tab knows it is current (its route matches)
29947
+ * long before the binder could work it out, so it reports instead of the
29948
+ * binder guessing — see `Link`'s use of this context.
29949
+ *
29950
+ * Its own module so `link.jsx` can read it without importing the binder.
29951
+ *
29952
+ * @type {import("preact").Context<{ reportCurrent: (current: boolean) => void } | null>}
29953
+ */
29954
+ const BinderItemContext = createContext(null);
29955
+
29944
29956
  /*
29945
29957
  * Custom hook to apply semi-transparent color when an element should be dimmed.
29946
29958
  *
@@ -29978,7 +29990,7 @@ const useDimColorWhen = (elementRef, shouldDim) => {
29978
29990
  });
29979
29991
  };
29980
29992
 
29981
- installImportMetaCssBuild(import.meta);const css$N = /* css */`
29993
+ installImportMetaCssBuild(import.meta);const css$P = /* css */`
29982
29994
  @layer navi {
29983
29995
  .navi_link {
29984
29996
  --link-border-radius: unset;
@@ -30053,7 +30065,12 @@ installImportMetaCssBuild(import.meta);const css$N = /* css */`
30053
30065
  aspect-ratio: inherit;
30054
30066
  padding-top: var(--x-link-padding-top);
30055
30067
  padding-right: var(--x-link-padding-right);
30056
- padding-bottom: var(--link-loading-outline-size);
30068
+ /* The loading underline is drawn inside the box: its own room is kept at
30069
+ the bottom on top of whatever padding was asked for, so a link that
30070
+ starts loading never reflows. */
30071
+ padding-bottom: calc(
30072
+ var(--x-link-padding-bottom) + var(--link-loading-outline-size)
30073
+ );
30057
30074
  padding-left: var(--x-link-padding-left);
30058
30075
  color: var(--x-link-color);
30059
30076
  text-decoration: var(--x-link-text-decoration);
@@ -30311,6 +30328,21 @@ const LinkStyleCSSVars = {
30311
30328
  color: "--link-color-selected"
30312
30329
  }
30313
30330
  };
30331
+ // A link placed inside a binder tab tells that tab whether it is the current
30332
+ // one. The binder cannot know: which page an url opens is the link's business,
30333
+ // and it is settled before the binder renders anything.
30334
+ const useReportCurrentToBinderItem = current => {
30335
+ const reportCurrent = useContext(BinderItemContext);
30336
+ useLayoutEffect(() => {
30337
+ if (!reportCurrent) {
30338
+ return undefined;
30339
+ }
30340
+ reportCurrent(current);
30341
+ return () => {
30342
+ reportCurrent(false);
30343
+ };
30344
+ }, [reportCurrent, current]);
30345
+ };
30314
30346
  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
30347
  const LinkPseudoElements = ["::-navi-loader", "::-navi-indicator"];
30316
30348
  Object.assign(PSEUDO_CLASSES, {
@@ -30400,7 +30432,7 @@ Object.assign(PSEUDO_CLASSES, {
30400
30432
  * @param {boolean} [props.readOnly]
30401
30433
  */
30402
30434
  const Link = props => {
30403
- import.meta.css = [css$N, "@jsenv/navi/src/nav/link/link.jsx"];
30435
+ import.meta.css = [css$P, "@jsenv/navi/src/nav/link/link.jsx"];
30404
30436
  if (props.route) {
30405
30437
  return jsx(LinkWithRoute, {
30406
30438
  ...props
@@ -30489,6 +30521,7 @@ const LinkPlain = props => {
30489
30521
  isCurrent
30490
30522
  } = getHrefTargetInfo(href);
30491
30523
  const innerCurrent = current || isCurrent;
30524
+ useReportCurrentToBinderItem(innerCurrent);
30492
30525
  controlHostProps.basePseudoState = {
30493
30526
  ...basePseudoState,
30494
30527
  ":visited": visited,
@@ -30634,7 +30667,7 @@ installImportMetaCssBuild(import.meta);/**
30634
30667
  * TabList component with support for horizontal and vertical layouts
30635
30668
  * https://dribbble.com/search/tabs
30636
30669
  */
30637
- const css$M = /* css */`
30670
+ const css$O = /* css */`
30638
30671
  @layer navi {
30639
30672
  .navi_nav {
30640
30673
  --nav-border: none;
@@ -30727,20 +30760,60 @@ const css$M = /* css */`
30727
30760
  }
30728
30761
  }
30729
30762
 
30730
- &[data-panel-border-connection] {
30731
- --nav-border-width: 10px;
30763
+ /* Folder tabs: the current tab and the panel share one surface. Every tab
30764
+ carries the panel's line on the side facing it, and the current one
30765
+ paints that side with the panel background instead, so the line opens
30766
+ there. The negative margin makes the two lines overlap — without it the
30767
+ panel's own line would still be drawn under the tabs; and since a tab
30768
+ background is painted under its border too, a tab with a background of
30769
+ its own would otherwise hide the panel's line over its width.
30770
+
30771
+ The line is 1px and never thicker: past that, the corner where a square
30772
+ tab meets the panel shows the miter as a visible notch. */
30773
+ &[data-panel-position] {
30774
+ --nav-border-color: transparent;
30775
+ --nav-tab-border-radius: 0px;
30776
+
30777
+ --x-nav-panel-background: var(
30778
+ --nav-panel-background,
30779
+ var(--link-background-current, white)
30780
+ );
30781
+
30732
30782
  position: relative;
30733
30783
  z-index: 1;
30734
30784
 
30735
30785
  .navi_link {
30736
- border: var(--nav-border-width) solid transparent;
30786
+ border: 1px solid transparent;
30787
+
30788
+ &[data-href-current] {
30789
+ border-color: var(--nav-border-color);
30790
+ }
30791
+ }
30792
+
30793
+ &[data-panel-position="after"] {
30794
+ margin-bottom: -1px;
30737
30795
 
30738
- &[data-tab-selected] {
30739
- border-color: gray;
30740
- border-bottom-color: var(--nav-background);
30796
+ .navi_link {
30797
+ border-bottom-color: var(--nav-border-color);
30741
30798
 
30742
- border-top-left-radius: 5px;
30743
- border-top-right-radius: 5px;
30799
+ &[data-href-current] {
30800
+ border-bottom-color: var(--x-nav-panel-background);
30801
+ border-top-left-radius: var(--nav-tab-border-radius);
30802
+ border-top-right-radius: var(--nav-tab-border-radius);
30803
+ }
30804
+ }
30805
+ }
30806
+ &[data-panel-position="before"] {
30807
+ margin-top: -1px;
30808
+
30809
+ .navi_link {
30810
+ border-top-color: var(--nav-border-color);
30811
+
30812
+ &[data-href-current] {
30813
+ border-top-color: var(--x-nav-panel-background);
30814
+ border-bottom-right-radius: var(--nav-tab-border-radius);
30815
+ border-bottom-left-radius: var(--nav-tab-border-radius);
30816
+ }
30744
30817
  }
30745
30818
  }
30746
30819
  }
@@ -30766,11 +30839,10 @@ const Nav = ({
30766
30839
  expandX,
30767
30840
  linkBorderRadiusInherit,
30768
30841
  panelPosition,
30769
- // before or after
30770
- panelBorderConnection,
30842
+ // "before" or "after": which side the panel sits on, turning the nav into folder tabs
30771
30843
  ...props
30772
30844
  }) => {
30773
- import.meta.css = [css$M, "@jsenv/navi/src/nav/link/nav.jsx"];
30845
+ import.meta.css = [css$O, "@jsenv/navi/src/nav/link/nav.jsx"];
30774
30846
  children = toChildArray(children);
30775
30847
  return jsx(Box, {
30776
30848
  as: "nav",
@@ -30781,7 +30853,6 @@ const Nav = ({
30781
30853
  "data-expand": expand || expandX ? "" : undefined,
30782
30854
  "data-vertical": vertical ? "" : undefined,
30783
30855
  "data-panel-position": panelPosition,
30784
- "data-panel-border-connection": panelBorderConnection ? "" : undefined,
30785
30856
  expand: expand,
30786
30857
  expandX: expandX,
30787
30858
  spacing: spacing,
@@ -30794,6 +30865,1268 @@ const Nav = ({
30794
30865
  });
30795
30866
  };
30796
30867
 
30868
+ /**
30869
+ * The binder outline: one closed path wrapping the tab row AND the page, with
30870
+ * the current tab opening into the page through concave junction curves.
30871
+ *
30872
+ * Why a path instead of CSS borders — CSS cannot do either of these:
30873
+ * 1. Omit one side of a border while keeping the surrounding corners intact
30874
+ * (the current tab has no border on the page side, so it merges with it).
30875
+ * 2. Curve a corner inward (concave), which is what a tab-to-page junction
30876
+ * is. `border-radius` only bulges outward.
30877
+ *
30878
+ * Everything here is in "path space": the main axis (along the tab row) is x
30879
+ * and the cross axis goes from the tabs toward the page, whatever the real
30880
+ * orientation. Tabs left/right reuse the top/bottom builders and transpose the
30881
+ * result at the end.
30882
+ *
30883
+ * The path is a centerline: it runs half a border-width inside the box edges,
30884
+ * so a stroke of `borderWidth` covers exactly the border area the CSS borders
30885
+ * of the inactive tabs occupy, and the two line up.
30886
+ */
30887
+
30888
+ // Low-level arc helpers: produce SVG arc commands with an explicit sweep flag.
30889
+ // sweep=1 → clockwise arc; sweep=0 → counter-clockwise arc.
30890
+ const arcSweep1 = (r, dx, dy) => {
30891
+ if (r <= 0) {
30892
+ return `l ${dx},${dy}`;
30893
+ }
30894
+ return `a ${r},${r} 0 0 1 ${dx},${dy}`;
30895
+ };
30896
+ const arcSweep0 = (r, dx, dy) => {
30897
+ if (r <= 0) {
30898
+ return `l ${dx},${dy}`;
30899
+ }
30900
+ return `a ${r},${r} 0 0 0 ${dx},${dy}`;
30901
+ };
30902
+ // Semantic aliases used when drawing the CW outer path.
30903
+ // In a CW path: convex (outward) corners use sweep=1, concave (inward) use sweep=0.
30904
+ const arcConvex = arcSweep1;
30905
+ const arcConcave = arcSweep0;
30906
+
30907
+ // Swaps x and y in a command list produced by the path builders (only
30908
+ // M/h/v/l/a/Z commands, in the exact format they emit). Swapping axes is a
30909
+ // mirror transform, so arc sweep flags must flip.
30910
+ const transposePath = (commands) =>
30911
+ commands.map((command) => {
30912
+ const parts = command.split(" ");
30913
+ const type = parts[0];
30914
+ if (type === "M" || type === "l") {
30915
+ const [x, y] = parts[1].split(",");
30916
+ return `${type} ${y},${x}`;
30917
+ }
30918
+ if (type === "h") {
30919
+ return `v ${parts[1]}`;
30920
+ }
30921
+ if (type === "v") {
30922
+ return `h ${parts[1]}`;
30923
+ }
30924
+ if (type === "a") {
30925
+ const sweep = parts[4] === "1" ? "0" : "1";
30926
+ const [dx, dy] = parts[5].split(",");
30927
+ return `a ${parts[1]} ${parts[2]} ${parts[3]} ${sweep} ${dy},${dx}`;
30928
+ }
30929
+ return command;
30930
+ });
30931
+
30932
+ /**
30933
+ * @param {object} params
30934
+ * @param {number} params.tabX - Where the current tab starts along the tab row.
30935
+ * @param {number} params.tabWidth - Its size along the tab row.
30936
+ * @param {number} params.tabRowHeight - From the outer edge of the tab row to
30937
+ * the junction line where tabs meet the page.
30938
+ * @param {number} params.panelWidth - The whole binder, along the tab row.
30939
+ * @param {number} params.panelHeight - From the junction line to the far edge
30940
+ * of the page.
30941
+ * @param {number} params.gapBeforeTabs - Free room before the first tab, and
30942
+ * @param {number} params.gapAfterTabs - after the last one: a page corner on
30943
+ * the tab-row side is only rounded when its gap has room for the radius.
30944
+ * @param {number} params.outerRadius - The corners of the shape as a whole:
30945
+ * the page's, and the ones the tab row shares with it. Square when the
30946
+ * binder fills its container, rounded when it sits in a rounded one.
30947
+ * @param {number} params.tabRadius - The tabs' own corners, away from the
30948
+ * page, and the concave curves where a tab opens into it. Independent: tabs
30949
+ * can be rounded inside a square binder, and the other way round.
30950
+ * @param {"top"|"bottom"|"left"|"right"} params.tabsPosition
30951
+ * @returns {string} An SVG path `d` attribute.
30952
+ */
30953
+ const buildBinderPath = ({
30954
+ tabX,
30955
+ tabWidth,
30956
+ tabRowHeight,
30957
+ panelWidth,
30958
+ panelHeight,
30959
+ borderWidth,
30960
+ outerRadius,
30961
+ tabRadius,
30962
+ gapBeforeTabs,
30963
+ gapAfterTabs,
30964
+ tabsPosition,
30965
+ }) => {
30966
+ const h = borderWidth / 2;
30967
+ const withoutBorder = (radius) => (radius - h < 0 ? 0 : radius - h);
30968
+ const r = withoutBorder(tabRadius);
30969
+ const rOuter = withoutBorder(outerRadius);
30970
+ // Page corners on the tab-row side only get rounded when the gap before /
30971
+ // after the tabs leaves room for the full radius.
30972
+ const gapAfter = gapAfterTabs < 0 ? 0 : gapAfterTabs;
30973
+ const rTR = gapAfter >= rOuter ? rOuter : 0;
30974
+ const gapBefore = gapBeforeTabs < 0 ? 0 : gapBeforeTabs;
30975
+ const rTL = gapBefore >= rOuter ? rOuter : 0;
30976
+
30977
+ const params = {
30978
+ tabX,
30979
+ tabWidth,
30980
+ tabRowHeight,
30981
+ panelWidth,
30982
+ panelHeight,
30983
+ h,
30984
+ r,
30985
+ rOuter,
30986
+ rTR,
30987
+ rTL,
30988
+ };
30989
+ const d =
30990
+ tabsPosition === "top" || tabsPosition === "left"
30991
+ ? buildBinderCenterlinePathAbove(params)
30992
+ : buildBinderCenterlinePathBelow(params);
30993
+ const commands =
30994
+ tabsPosition === "left" || tabsPosition === "right" ? transposePath(d) : d;
30995
+ return commands.join(" ");
30996
+ };
30997
+
30998
+ // Builds the centerline path when tabs are ABOVE the panel.
30999
+ // CW path: M(tabLeft+r, top) → tab top → tab TR → [right junction] → panel TR
31000
+ // → panel right ↓ → panel BR → panel bottom ← → panel BL → panel left ↑ → [left junction] → tab TL → Z
31001
+ const buildBinderCenterlinePathAbove = ({
31002
+ tabX,
31003
+ tabWidth,
31004
+ tabRowHeight,
31005
+ panelWidth,
31006
+ panelHeight,
31007
+ h,
31008
+ r,
31009
+ rOuter,
31010
+ rTR,
31011
+ rTL,
31012
+ }) => {
31013
+ const hasRightJunction = tabX + tabWidth < panelWidth;
31014
+ const hasLeftJunction = tabX > 0;
31015
+
31016
+ const left = h;
31017
+ const top = h;
31018
+ const right = panelWidth - h;
31019
+ const bottom = tabRowHeight + panelHeight - h;
31020
+ const tabLeft = tabX + h;
31021
+ const tabRight = tabX + tabWidth - h;
31022
+ const junctionY = tabRowHeight - h;
31023
+
31024
+ const rVSpace = junctionY - top - r;
31025
+ const rTotalHSpace = right - tabRight;
31026
+ const rFits = hasRightJunction && rVSpace >= r && rTotalHSpace >= r + rTR;
31027
+
31028
+ const lVSpace = junctionY - top - r;
31029
+ const lTotalHSpace = tabLeft - left - rTL; // capped by gap left of first tab
31030
+ // lFits: room for panel TL arc (rTL) + concave junction arc (r)
31031
+ const lFits = hasLeftJunction && lVSpace >= r && lTotalHSpace >= r;
31032
+ // lTight: only room for panel TL arc, skip concave — favor panel rounding
31033
+ const lTight = hasLeftJunction && lVSpace >= r && lTotalHSpace >= 0;
31034
+
31035
+ const d = [];
31036
+ d.push(`M ${tabLeft + r},${top}`);
31037
+ d.push(`h ${tabRight - r - (tabLeft + r)}`);
31038
+
31039
+ // ── Right side ────────────────────────────────────────────────────────────
31040
+ if (!hasRightJunction) {
31041
+ d.push(arcConvex(r, r, r));
31042
+ d.push(`v ${bottom - top - r - rOuter}`);
31043
+ } else if (rFits) {
31044
+ d.push(arcConvex(r, r, r)); // tab TR: right→down
31045
+ d.push(`v ${rVSpace - r}`); // tab right ↓ to concave arc start
31046
+ d.push(arcConcave(r, r, r)); // junction concave: down→right
31047
+ d.push(`h ${rTotalHSpace - r - rTR}`); // panel top →
31048
+ d.push(arcConvex(rTR, rTR, rTR)); // panel TR: right→down
31049
+ d.push(`v ${bottom - junctionY - rTR - rOuter}`); // panel right ↓
31050
+ } else {
31051
+ d.push(arcConvex(r, r, r)); // tab TR: right→down
31052
+ d.push(`v ${rVSpace}`); // straight down to junctionY
31053
+ d.push(`h ${rTotalHSpace - rTR}`); // panel top →
31054
+ d.push(arcConvex(rTR, rTR, rTR)); // panel TR: right→down (clamped)
31055
+ d.push(`v ${bottom - junctionY - rTR - rOuter}`); // panel right ↓
31056
+ }
31057
+
31058
+ // ── Panel BR → bottom → BL ────────────────────────────────────────────────
31059
+ d.push(arcConvex(rOuter, -rOuter, rOuter));
31060
+ d.push(`h -${right - left - 2 * rOuter}`);
31061
+ d.push(arcConvex(rOuter, -rOuter, -rOuter));
31062
+
31063
+ // ── Panel left ↑ + left junction back to M ───────────────────────────────
31064
+ if (!hasLeftJunction) {
31065
+ d.push(`v -${bottom - top - rOuter - r}`);
31066
+ d.push(arcConvex(r, r, -r)); // panel TL = tab TL: up→right
31067
+ } else if (lFits) {
31068
+ d.push(`v -${bottom - junctionY - rOuter - rTL}`); // panel left ↑ to (left, junctionY+rTL)
31069
+ d.push(arcConvex(rTL, rTL, -rTL)); // panel TL: up→right
31070
+ d.push(`h ${lTotalHSpace - r}`); // panel top → to (tabLeft-r, junctionY)
31071
+ d.push(arcConcave(r, r, -r)); // junction concave: right→up
31072
+ d.push(`v -${lVSpace - r}`); // tab left ↑
31073
+ d.push(arcConvex(r, r, -r)); // tab TL: up→right
31074
+ } else if (lTight) {
31075
+ // Panel TL rounded, no concave — square tab corner
31076
+ d.push(`v -${bottom - junctionY - rOuter - rTL}`); // panel left ↑ to (left, junctionY+rTL)
31077
+ d.push(arcConvex(rTL, rTL, -rTL)); // panel TL: up→right
31078
+ d.push(`h ${lTotalHSpace}`); // panel top → to (tabLeft, junctionY)
31079
+ d.push(`v -${lVSpace}`); // tab left ↑
31080
+ d.push(arcConvex(r, r, -r)); // tab TL: up→right
31081
+ } else {
31082
+ d.push(`v -${bottom - junctionY - rOuter}`); // panel left ↑ to junctionY
31083
+ d.push(`h ${lTotalHSpace}`); // panel top →
31084
+ d.push(`v -${lVSpace}`); // tab left ↑
31085
+ d.push(arcConvex(r, r, -r)); // tab TL: up→right
31086
+ }
31087
+
31088
+ d.push("Z");
31089
+ return d;
31090
+ };
31091
+
31092
+ // Builds the centerline path when tabs are BELOW the panel.
31093
+ // CW path: M(left+r, top) → panel top → panel TR → panel right ↓ → [right junction] →
31094
+ // tab right ↓ → tab BR → tab bottom ← → [left junction] → tab BL → tab left ↑ → panel left ↑ → panel TL → Z
31095
+ //
31096
+ // Right junction: panel right ↓ to junctionY (square corner), left along panel bottom gap,
31097
+ // concave notch arc (left→down), tab right ↓.
31098
+ // Left junction: tab left ↑, concave notch arc (up→left), left along panel bottom gap to panel left
31099
+ // (square corner), panel left ↑.
31100
+ const buildBinderCenterlinePathBelow = ({
31101
+ tabX,
31102
+ tabWidth,
31103
+ tabRowHeight,
31104
+ panelWidth,
31105
+ panelHeight,
31106
+ h,
31107
+ r,
31108
+ rOuter,
31109
+ rTR,
31110
+ rTL,
31111
+ }) => {
31112
+ const hasRightJunction = tabX + tabWidth < panelWidth;
31113
+ const hasLeftJunction = tabX > 0;
31114
+
31115
+ const left = h;
31116
+ const top = h;
31117
+ const right = panelWidth - h;
31118
+ const bottom = panelHeight + tabRowHeight - h;
31119
+ const tabLeft = tabX + h;
31120
+ const tabRight = tabX + tabWidth - h;
31121
+ const junctionY = panelHeight - h;
31122
+
31123
+ // Vertical room on the page's right/left sides, between its top corner
31124
+ // (outer) and the junction.
31125
+ const rPanelVSpace = junctionY - top - rOuter;
31126
+
31127
+ // Right junction: the page corner beside the last tab uses rTR.
31128
+ const rTotalHSpace = right - tabRight - rTR;
31129
+ // rFits: room for the page corner arc (rTR) + the concave junction arc (r)
31130
+ const rFits = hasRightJunction && rPanelVSpace >= r && rTotalHSpace >= r;
31131
+ // rTight: only room for the page corner arc, skip concave — favor the page
31132
+ const rTight = hasRightJunction && rPanelVSpace >= r && rTotalHSpace >= 0;
31133
+
31134
+ // Left junction: the page corner beside the first tab uses rTL.
31135
+ const lTotalHSpace = tabLeft - left - rTL;
31136
+ const lFits = hasLeftJunction && rPanelVSpace >= r && lTotalHSpace >= r;
31137
+ const lTight = hasLeftJunction && rPanelVSpace >= r && lTotalHSpace >= 0;
31138
+
31139
+ const d = [];
31140
+
31141
+ // M: start after the page's TL arc, going right along the page top
31142
+ d.push(`M ${left + rOuter},${top}`);
31143
+ d.push(`h ${right - left - 2 * rOuter}`); // page top →
31144
+
31145
+ // ── Right side: page right ↓ then (junction or direct) tab right ↓ ──────
31146
+ if (!hasRightJunction) {
31147
+ d.push(arcConvex(rOuter, rOuter, rOuter)); // page TR: right→down
31148
+ d.push(`v ${bottom - top - rOuter - r}`); // full right side ↓
31149
+ d.push(arcConvex(r, -r, r)); // tab BR: down→left
31150
+ } else if (rFits) {
31151
+ d.push(arcConvex(rOuter, rOuter, rOuter)); // page TR: right→down
31152
+ d.push(`v ${rPanelVSpace - rTR}`); // page right ↓ to (right, junctionY-rTR)
31153
+ d.push(arcConvex(rTR, -rTR, rTR)); // page corner: down→left
31154
+ d.push(`h -${rTotalHSpace - r}`); // page bottom ← to (tabRight+r, junctionY)
31155
+ d.push(arcConcave(r, -r, r)); // junction concave: left→down
31156
+ d.push(`v ${bottom - junctionY - 2 * r}`); // tab right ↓ to (tabRight, bottom-r)
31157
+ d.push(arcConvex(r, -r, r)); // tab BR: down→left
31158
+ } else if (rTight) {
31159
+ // Page corner rounded, no concave — square tab corner
31160
+ d.push(arcConvex(rOuter, rOuter, rOuter)); // page TR: right→down
31161
+ d.push(`v ${rPanelVSpace - rTR}`); // page right ↓ to (right, junctionY-rTR)
31162
+ d.push(arcConvex(rTR, -rTR, rTR)); // page corner: down→left
31163
+ d.push(`h -${rTotalHSpace}`); // page bottom ← to (tabRight, junctionY)
31164
+ d.push(`v ${bottom - junctionY - r}`); // tab right ↓ to (tabRight, bottom-r)
31165
+ d.push(arcConvex(r, -r, r)); // tab BR: down→left
31166
+ } else {
31167
+ // Very tight: all square corners
31168
+ d.push(arcConvex(rOuter, rOuter, rOuter)); // page TR: right→down
31169
+ d.push(`v ${rPanelVSpace}`); // page right ↓ to junctionY
31170
+ d.push(`h -${right - tabRight}`); // page bottom ← to tabRight
31171
+ d.push(`v ${bottom - junctionY - r}`); // tab right ↓ to (tabRight, bottom-r)
31172
+ d.push(arcConvex(r, -r, r)); // tab BR: down→left
31173
+ }
31174
+
31175
+ // ── Tab bottom ← ──────────────────────────────────────────────────────────
31176
+ d.push(`h -${tabRight - r - (tabLeft + r)}`); // tab bottom ←
31177
+
31178
+ // ── Left side: tab BL → tab left ↑ → (junction or direct) → page left ↑ ──
31179
+ if (!hasLeftJunction) {
31180
+ d.push(arcConvex(r, -r, -r)); // tab BL: left→up
31181
+ d.push(`v -${bottom - top - rOuter - r}`); // full left side ↑
31182
+ d.push(arcConvex(rOuter, rOuter, -rOuter)); // page TL: up→right → M
31183
+ } else if (lFits) {
31184
+ d.push(arcConvex(r, -r, -r)); // tab BL: left→up
31185
+ d.push(`v -${bottom - junctionY - 2 * r}`); // tab left ↑ to (tabLeft, junctionY+r)
31186
+ d.push(arcConcave(r, -r, -r)); // junction concave: up→left
31187
+ d.push(`h -${lTotalHSpace - r}`); // page bottom ← to (left+rTL, junctionY)
31188
+ d.push(arcConvex(rTL, -rTL, -rTL)); // page corner: left→up
31189
+ d.push(`v -${rPanelVSpace - rTL}`); // page left ↑ to (left, top+rOuter)
31190
+ d.push(arcConvex(rOuter, rOuter, -rOuter)); // page TL: up→right → M
31191
+ } else if (lTight) {
31192
+ // Page corner rounded, no concave — square tab corner
31193
+ d.push(arcConvex(r, -r, -r)); // tab BL: left→up
31194
+ d.push(`v -${bottom - junctionY - r}`); // tab left ↑ to (tabLeft, junctionY)
31195
+ d.push(`h -${lTotalHSpace}`); // page bottom ← to (left+rTL, junctionY)
31196
+ d.push(arcConvex(rTL, -rTL, -rTL)); // page corner: left→up
31197
+ d.push(`v -${rPanelVSpace - rTL}`); // page left ↑ to (left, top+rOuter)
31198
+ d.push(arcConvex(rOuter, rOuter, -rOuter)); // page TL: up→right → M
31199
+ } else {
31200
+ // Very tight: all square corners
31201
+ d.push(arcConvex(r, -r, -r)); // tab BL: left→up
31202
+ d.push(`v -${bottom - junctionY - r}`); // tab left ↑ to junctionY
31203
+ d.push(`h -${tabLeft - left}`); // page bottom ← to left
31204
+ d.push(`v -${rPanelVSpace}`); // page left ↑ to (left, top+rOuter)
31205
+ d.push(arcConvex(rOuter, rOuter, -rOuter)); // page TL: up→right → M
31206
+ }
31207
+
31208
+ d.push("Z");
31209
+ return d;
31210
+ };
31211
+
31212
+ installImportMetaCssBuild(import.meta);/**
31213
+ * A binder: tabs and the page they open, drawn as one shape.
31214
+ *
31215
+ * Unlike a `Nav`, a binder owns its content — the tab and the page it shows are
31216
+ * the same item, declared together:
31217
+ *
31218
+ * <Binder>
31219
+ * <Binder.Item value="a" label="Approved">…the page…</Binder.Item>
31220
+ * </Binder>
31221
+ *
31222
+ * Layers, and why they are what they are:
31223
+ * - The outline (tab row + page, with the concave junctions around the current
31224
+ * tab) is a single SVG path — see binder_path.js for why CSS borders cannot
31225
+ * draw it. It covers the whole binder and sits above the inactive tabs but
31226
+ * below the current tab and the page.
31227
+ * - The inactive tabs keep plain CSS borders: the path only runs along the
31228
+ * current tab, so the top of an inactive tab is its own to draw. Their border
31229
+ * width matches the path's stroke and lands in the same band, which is what
31230
+ * makes the two read as one continuous line.
31231
+ * - Every tab reserves that band on the page side with a margin, so the stroke
31232
+ * never lands on a tab's own edge.
31233
+ *
31234
+ * Border width participates in layout (it is added to the tab and page
31235
+ * padding): a thick border grows the binder rather than eating into the text.
31236
+ */
31237
+ const css$N = /* css */`
31238
+ @layer navi {
31239
+ .navi_binder {
31240
+ --binder-border-width: var(--navi-control-border-width);
31241
+ --binder-border-radius: var(--navi-control-border-radius);
31242
+ /* The tabs' own corners. Their own knob because the two answer different
31243
+ questions: the outer radius follows the container the binder sits in
31244
+ (square when it fills the page), the tab radius is the tabs' shape. */
31245
+ --binder-tab-border-radius: var(--binder-border-radius);
31246
+ --binder-border-color: var(--navi-control-border-color);
31247
+ --binder-background: var(--navi-surface-color);
31248
+ /* The tabs that are not open read as the same paper, slightly shaded:
31249
+ derived from the border color so one override themes both, in light
31250
+ and dark alike. */
31251
+ --binder-tab-background: color-mix(
31252
+ in srgb,
31253
+ var(--binder-border-color) 12%,
31254
+ var(--binder-background)
31255
+ );
31256
+ --binder-tab-border-color: var(--binder-border-color);
31257
+ --binder-tab-color: var(--navi-color-secondary);
31258
+ --binder-tab-background-hover: color-mix(
31259
+ in srgb,
31260
+ var(--binder-border-color) 22%,
31261
+ var(--binder-background)
31262
+ );
31263
+ --binder-padding-x: var(--navi-s);
31264
+ --binder-padding-y: var(--navi-xs);
31265
+ --binder-page-padding: var(--navi-m);
31266
+ }
31267
+ }
31268
+
31269
+ .navi_binder {
31270
+ position: relative;
31271
+ display: flex;
31272
+ width: fit-content;
31273
+ align-items: stretch;
31274
+
31275
+ &[data-tabs-position="top"] {
31276
+ flex-direction: column;
31277
+ }
31278
+ &[data-tabs-position="bottom"] {
31279
+ flex-direction: column-reverse;
31280
+ }
31281
+ &[data-tabs-position="left"] {
31282
+ flex-direction: row;
31283
+ }
31284
+ &[data-tabs-position="right"] {
31285
+ flex-direction: row-reverse;
31286
+ }
31287
+ }
31288
+
31289
+ .navi_binder_outline {
31290
+ position: absolute;
31291
+ inset: 0;
31292
+ z-index: 1;
31293
+ pointer-events: none;
31294
+ overflow: visible;
31295
+ }
31296
+
31297
+ .navi_binder_tabs {
31298
+ display: flex;
31299
+ /* The page decides how wide the binder is; the tab row only fills it.
31300
+ Sizing it to 0 keeps its labels out of the binder's intrinsic size (a
31301
+ long one would otherwise stretch the whole thing) while min-size brings
31302
+ it back to the width the page settled on — which is what leaves the
31303
+ labels something to truncate against. */
31304
+ .navi_binder[data-tabs-position="top"] &,
31305
+ .navi_binder[data-tabs-position="bottom"] & {
31306
+ width: 0;
31307
+ min-width: 100%;
31308
+ }
31309
+ .navi_binder[data-tabs-position="left"] &,
31310
+ .navi_binder[data-tabs-position="right"] & {
31311
+ height: 0;
31312
+ min-height: 100%;
31313
+ }
31314
+ /* Tabs of unequal size line up on the page side, so the junction band is
31315
+ one straight line whatever their content. */
31316
+ .navi_binder[data-tabs-position="top"] & {
31317
+ align-items: flex-end;
31318
+ }
31319
+ .navi_binder[data-tabs-position="bottom"] & {
31320
+ align-items: flex-start;
31321
+ }
31322
+ /* A column of tabs is as wide as its widest one, all of them that wide, so
31323
+ the junction band is one straight line here too. */
31324
+ .navi_binder[data-tabs-position="left"] &,
31325
+ .navi_binder[data-tabs-position="right"] & {
31326
+ flex-direction: column;
31327
+ align-items: stretch;
31328
+ }
31329
+ }
31330
+
31331
+ .navi_binder_tab {
31332
+ position: relative;
31333
+ z-index: 0;
31334
+ /* A flex item refuses to go under its content unless told to; without this
31335
+ a long label widens the tab row instead of truncating. */
31336
+ min-width: 0;
31337
+ min-height: 0;
31338
+ /* The border is part of the tab's box on every tab, current one included
31339
+ (transparent there — the path draws it), so opening a tab never moves
31340
+ anything. */
31341
+ padding: var(--binder-padding-y) var(--binder-padding-x);
31342
+ flex: 0 1 auto;
31343
+ color: var(--binder-tab-color);
31344
+ font: inherit;
31345
+ text-align: center;
31346
+ text-decoration: none;
31347
+ background: var(--binder-tab-background);
31348
+ border: var(--binder-border-width) solid var(--binder-tab-border-color);
31349
+ cursor: pointer;
31350
+ /* "stretch" is an equal share each, whatever a tab holds — a basis read
31351
+ from the content would hand the longest label the widest tab. */
31352
+ .navi_binder[data-tabs-align="stretch"] & {
31353
+ flex: 1 1 0;
31354
+ }
31355
+
31356
+ /* The navi attributes rather than the CSS pseudo-classes: they are what a
31357
+ demo can hold with pseudoState={{ ":focus-visible": true }}. */
31358
+ &[data-hover] {
31359
+ background: var(--binder-tab-background-hover);
31360
+ }
31361
+ /* The ring would be cut by the neighbours and by the outline band around
31362
+ it, so it is drawn inside and lifted above them. */
31363
+ &[data-focus-visible] {
31364
+ z-index: 3;
31365
+ outline: var(--navi-focus-outline-width) solid
31366
+ var(--navi-focus-outline-color);
31367
+ outline-offset: calc(-1 * var(--navi-focus-outline-width));
31368
+ }
31369
+ &[data-current] {
31370
+ z-index: 2;
31371
+ color: inherit;
31372
+ background: transparent;
31373
+ border-color: transparent;
31374
+ cursor: default;
31375
+ }
31376
+ /* Adjacent tabs share one line rather than stacking two borders. */
31377
+ &:not(:first-child) {
31378
+ margin-inline-start: calc(-1 * var(--binder-border-width));
31379
+ }
31380
+
31381
+ /* The margin on the page side is the junction band the path draws in; the
31382
+ corners away from the page are the ones that round. */
31383
+ .navi_binder[data-tabs-position="top"] & {
31384
+ margin-bottom: var(--binder-border-width);
31385
+ border-bottom: none;
31386
+ border-radius: var(--binder-tab-border-radius)
31387
+ var(--binder-tab-border-radius) 0 0;
31388
+ }
31389
+ .navi_binder[data-tabs-position="bottom"] & {
31390
+ margin-top: var(--binder-border-width);
31391
+ border-top: none;
31392
+ border-radius: 0 0 var(--binder-tab-border-radius)
31393
+ var(--binder-tab-border-radius);
31394
+ }
31395
+ .navi_binder[data-tabs-position="left"] & {
31396
+ margin-right: var(--binder-border-width);
31397
+ border-right: none;
31398
+ border-radius: var(--binder-tab-border-radius) 0 0
31399
+ var(--binder-tab-border-radius);
31400
+ }
31401
+ .navi_binder[data-tabs-position="right"] & {
31402
+ margin-left: var(--binder-border-width);
31403
+ border-left: none;
31404
+ border-radius: 0 var(--binder-tab-border-radius)
31405
+ var(--binder-tab-border-radius) 0;
31406
+ }
31407
+ /* A column of tabs shares its main axis with the block direction, where
31408
+ margin-inline-start would collapse the wrong pair. */
31409
+ .navi_binder[data-tabs-position="left"] &:not(:first-child),
31410
+ .navi_binder[data-tabs-position="right"] &:not(:first-child) {
31411
+ margin-inline-start: 0;
31412
+ margin-top: calc(-1 * var(--binder-border-width));
31413
+ }
31414
+ }
31415
+
31416
+ /* A tab that grows with its label would push the binder wider than the page
31417
+ it opens; by default a label stays on one line and is cut with an ellipsis.
31418
+ maxLines={n} lets it use n lines, maxLines={false} lets it wrap freely. */
31419
+ .navi_binder_tab_label {
31420
+ display: block;
31421
+
31422
+ &[data-max-lines="1"] {
31423
+ text-overflow: ellipsis;
31424
+ white-space: nowrap;
31425
+ overflow: hidden;
31426
+ }
31427
+ &[data-max-lines]:not([data-max-lines="1"]) {
31428
+ display: -webkit-box;
31429
+ overflow: hidden;
31430
+ -webkit-box-orient: vertical;
31431
+ -webkit-line-clamp: var(--binder-tab-max-lines);
31432
+ }
31433
+ }
31434
+
31435
+ .navi_binder_page {
31436
+ position: relative;
31437
+ z-index: 2;
31438
+ /* Same reservation as the tabs, on the three sides the path runs along. */
31439
+ padding: calc(var(--binder-page-padding) + var(--binder-border-width));
31440
+ flex: 1;
31441
+ color: inherit;
31442
+
31443
+ /* An app shell: the binder is the window, the page is what scrolls in it,
31444
+ and the tab row stays put without being fixed — it is simply the part of
31445
+ the binder that does not scroll. min-size:0 because a flex item refuses
31446
+ to shrink under its content, which is what makes overflow do nothing. */
31447
+ .navi_binder[data-scrollable-page] & {
31448
+ min-width: 0;
31449
+ min-height: 0;
31450
+ overflow: auto;
31451
+ }
31452
+
31453
+ .navi_binder[data-tabs-position="top"] & {
31454
+ padding-top: var(--binder-page-padding);
31455
+ }
31456
+ .navi_binder[data-tabs-position="bottom"] & {
31457
+ padding-bottom: var(--binder-page-padding);
31458
+ }
31459
+ .navi_binder[data-tabs-position="left"] & {
31460
+ padding-left: var(--binder-page-padding);
31461
+ }
31462
+ .navi_binder[data-tabs-position="right"] & {
31463
+ padding-right: var(--binder-page-padding);
31464
+ }
31465
+ }
31466
+ `;
31467
+ const BinderStyleCSSVars = {
31468
+ borderWidth: "--binder-border-width",
31469
+ borderRadius: "--binder-border-radius",
31470
+ tabBorderRadius: "--binder-tab-border-radius",
31471
+ borderColor: "--binder-border-color",
31472
+ background: "--binder-background",
31473
+ tabBackground: "--binder-tab-background",
31474
+ tabBackgroundHover: "--binder-tab-background-hover",
31475
+ tabBorderColor: "--binder-tab-border-color",
31476
+ tabColor: "--binder-tab-color",
31477
+ paddingX: "--binder-padding-x",
31478
+ paddingY: "--binder-padding-y",
31479
+ pagePadding: "--binder-page-padding"
31480
+ };
31481
+ const TABS_ALIGN_TO_JUSTIFY_CONTENT = {
31482
+ start: "flex-start",
31483
+ center: "center",
31484
+ end: "flex-end",
31485
+ stretch: "stretch"
31486
+ };
31487
+
31488
+ /**
31489
+ * @type {import("ignore:preact").FunctionComponent<{
31490
+ * value?: any,
31491
+ * defaultValue?: any,
31492
+ * onChange?: (value: any) => void,
31493
+ * tabsPosition?: "top"|"bottom"|"left"|"right",
31494
+ * tabsAlign?: "start"|"center"|"end"|"stretch",
31495
+ * borderWidth?: string|number,
31496
+ * borderRadius?: string|number,
31497
+ * tabBorderRadius?: string|number,
31498
+ * borderColor?: string,
31499
+ * background?: string,
31500
+ * tabBackground?: string,
31501
+ * tabBackgroundHover?: string,
31502
+ * tabBorderColor?: string,
31503
+ * tabColor?: string,
31504
+ * paddingX?: string|number,
31505
+ * paddingY?: string|number,
31506
+ * pagePadding?: string|number,
31507
+ * }>}
31508
+ * @param {any} [props.value] - The open item, when the caller keeps that state.
31509
+ * Leave it out for a binder that keeps its own (see `defaultValue`).
31510
+ * @param {any} [props.defaultValue] - Which item is open to begin with;
31511
+ * defaults to the first one.
31512
+ * @param {"top"|"bottom"|"left"|"right"} [props.tabsPosition="top"] - Which
31513
+ * side of the page the tabs sit on.
31514
+ * @param {"start"|"center"|"end"|"stretch"} [props.tabsAlign="stretch"] - How
31515
+ * the tabs spread along that side.
31516
+ * @param {boolean} [props.scrollablePage] - Makes the page scroll inside the
31517
+ * binder instead of growing it. What an app shell wants: give the binder the
31518
+ * size of the window and the tab row stays in place while the page scrolls
31519
+ * under it — no `position: fixed` anywhere, so the tabs and the page are
31520
+ * still one shape and the junction still merges them. The trade is that the
31521
+ * document itself no longer scrolls.
31522
+ * @param {number|false} [props.maxLines=1] - How many lines a tab label may
31523
+ * use before being cut with an ellipsis; `false` lets it wrap freely. A tab
31524
+ * is never allowed to widen the binder past the page it opens, so the
31525
+ * default keeps every label on one line. Overridable per item.
31526
+ * @param {string|number} [props.borderWidth] - Thickness of the whole outline,
31527
+ * the inactive tabs included. It is added to the paddings, so a thick border
31528
+ * grows the binder instead of closing in on the text.
31529
+ * @param {string} [props.tabBorderColor] - The inactive tabs alone; the rest of
31530
+ * the outline follows `borderColor`.
31531
+ * @param {import("ignore:preact").ComponentChildren} props.children - `Binder.Item`s.
31532
+ */
31533
+ const Binder = ({
31534
+ children,
31535
+ value,
31536
+ defaultValue,
31537
+ onChange,
31538
+ tabsPosition = "top",
31539
+ tabsAlign = "stretch",
31540
+ maxLines = 1,
31541
+ scrollablePage,
31542
+ borderWidth,
31543
+ borderRadius,
31544
+ tabBorderRadius,
31545
+ paddingX,
31546
+ paddingY,
31547
+ pagePadding,
31548
+ ...props
31549
+ }) => {
31550
+ import.meta.css = [css$N, "@jsenv/navi/src/nav/binder/binder.jsx"];
31551
+ const items = toChildArray(children).map((child, index) => {
31552
+ const {
31553
+ value: itemValue,
31554
+ label,
31555
+ children: content,
31556
+ ...tabProps
31557
+ } = child.props;
31558
+ return {
31559
+ value: itemValue === undefined ? index : itemValue,
31560
+ label,
31561
+ content,
31562
+ // Everything else belongs to the tab: an item is how one styles and
31563
+ // configures the control that opens it.
31564
+ tabProps
31565
+ };
31566
+ });
31567
+ const [valueOwnState, setValueOwnState] = useState(defaultValue === undefined ? items[0]?.value : defaultValue);
31568
+ // A tab holding a Link knows it is the current one (its route matches) before
31569
+ // the binder could work it out, and says so — see BinderItemContext.
31570
+ const [reportedValue, setReportedValue] = useState(undefined);
31571
+ let wantedValue;
31572
+ if (value !== undefined) {
31573
+ wantedValue = value;
31574
+ } else if (reportedValue !== undefined) {
31575
+ wantedValue = reportedValue;
31576
+ } else {
31577
+ wantedValue = valueOwnState;
31578
+ }
31579
+ const currentIndex = items.findIndex(item => item.value === wantedValue);
31580
+ const currentItem = currentIndex === -1 ? items[0] : items[currentIndex];
31581
+ const currentValue = currentItem?.value;
31582
+ const open = item => {
31583
+ if (value === undefined) {
31584
+ setValueOwnState(item.value);
31585
+ }
31586
+ onChange?.(item.value);
31587
+ };
31588
+ const reportCurrent = (item, current) => {
31589
+ setReportedValue(previous => {
31590
+ if (current) {
31591
+ return item.value;
31592
+ }
31593
+ // Only the tab that claimed it may withdraw it, otherwise the tabs that
31594
+ // just stopped being current would erase the one that just became it.
31595
+ return previous === item.value ? undefined : previous;
31596
+ });
31597
+ };
31598
+ const vertical = tabsPosition === "left" || tabsPosition === "right";
31599
+ const tabsFirst = tabsPosition === "top" || tabsPosition === "left";
31600
+ const rootRef = useRef(null);
31601
+ const tabRefs = useRef([]);
31602
+ const onKeyDown = event => {
31603
+ const {
31604
+ key
31605
+ } = event;
31606
+ const previousKey = vertical ? "ArrowUp" : "ArrowLeft";
31607
+ const nextKey = vertical ? "ArrowDown" : "ArrowRight";
31608
+ let nextIndex;
31609
+ if (key === previousKey) {
31610
+ nextIndex = currentIndex <= 0 ? items.length - 1 : currentIndex - 1;
31611
+ } else if (key === nextKey) {
31612
+ nextIndex = currentIndex >= items.length - 1 ? 0 : currentIndex + 1;
31613
+ } else if (key === "Home") {
31614
+ nextIndex = 0;
31615
+ } else if (key === "End") {
31616
+ nextIndex = items.length - 1;
31617
+ } else {
31618
+ return;
31619
+ }
31620
+ event.preventDefault();
31621
+ open(items[nextIndex]);
31622
+ tabRefs.current[nextIndex]?.focus();
31623
+ };
31624
+ return jsxs(Box, {
31625
+ ref: rootRef,
31626
+ baseClassName: "navi_binder",
31627
+ "data-tabs-position": tabsPosition,
31628
+ "data-tabs-align": tabsAlign,
31629
+ "data-scrollable-page": scrollablePage ? "" : undefined,
31630
+ borderWidth: withPixelUnit(borderWidth),
31631
+ borderRadius: withPixelUnit(borderRadius),
31632
+ tabBorderRadius: withPixelUnit(tabBorderRadius),
31633
+ paddingX: withPixelUnit(paddingX),
31634
+ paddingY: withPixelUnit(paddingY),
31635
+ pagePadding: withPixelUnit(pagePadding),
31636
+ ...props,
31637
+ styleCSSVars: BinderStyleCSSVars,
31638
+ children: [jsx(BinderOutline, {
31639
+ rootRef: rootRef,
31640
+ currentValue: currentValue,
31641
+ tabsPosition: tabsPosition,
31642
+ vertical: vertical,
31643
+ tabsFirst: tabsFirst
31644
+ }), jsx("div", {
31645
+ className: "navi_binder_tabs",
31646
+ role: "tablist",
31647
+ "aria-orientation": vertical ? "vertical" : "horizontal",
31648
+ style: {
31649
+ justifyContent: TABS_ALIGN_TO_JUSTIFY_CONTENT[tabsAlign]
31650
+ },
31651
+ children: items.map((item, index) => jsx(BinderTab, {
31652
+ item: item,
31653
+ index: index,
31654
+ tabRefs: tabRefs,
31655
+ current: item === currentItem,
31656
+ maxLines: maxLines,
31657
+ onOpen: () => open(item),
31658
+ onKeyDown: onKeyDown,
31659
+ onReportCurrent: current => reportCurrent(item, current)
31660
+ }, item.value))
31661
+ }), jsx("div", {
31662
+ className: "navi_binder_page",
31663
+ role: "tabpanel",
31664
+ children: currentItem?.content
31665
+ })]
31666
+ });
31667
+ };
31668
+
31669
+ /**
31670
+ * @param {any} [props.value] - What identifies this item; defaults to its
31671
+ * position among its siblings.
31672
+ * @param {import("ignore:preact").ComponentChildren} [props.label] - What the tab
31673
+ * shows.
31674
+ * @param {import("../route.js").Route} [props.route] - Makes the tab a `Link`
31675
+ * to that route, and the binder follows the url: the open item is the one
31676
+ * whose route matches, no `value`/`onChange` needed.
31677
+ * @param {string} [props.href] - Same, for a plain url.
31678
+ * @param {import("ignore:preact").ComponentChildren} [props.children] - The page.
31679
+ *
31680
+ * Every other prop goes to the tab (a `Box`, or a `Link` when routed): an item
31681
+ * styles and configures the control that opens it.
31682
+ */
31683
+ // Read by Binder, never rendered on its own: an item declares a tab AND its
31684
+ // page, and the binder needs both before it renders either.
31685
+ const BinderItem = () => null;
31686
+ Binder.Item = BinderItem;
31687
+ const BINDER_TAB_PSEUDO_CLASSES = [":hover", ":active", ":focus-visible"];
31688
+ const BinderTab = ({
31689
+ item,
31690
+ index,
31691
+ tabRefs,
31692
+ current,
31693
+ maxLines,
31694
+ onOpen,
31695
+ onKeyDown,
31696
+ onReportCurrent
31697
+ }) => {
31698
+ const {
31699
+ label,
31700
+ tabProps
31701
+ } = item;
31702
+ const {
31703
+ maxLines: itemMaxLines = maxLines,
31704
+ onClick,
31705
+ ...rest
31706
+ } = tabProps;
31707
+ return jsx(Box, {
31708
+ as: "button",
31709
+ type: "button",
31710
+ baseClassName: "navi_binder_tab",
31711
+ pseudoClasses: BINDER_TAB_PSEUDO_CLASSES,
31712
+ ref: element => {
31713
+ tabRefs.current[index] = element;
31714
+ },
31715
+ role: "tab",
31716
+ "aria-selected": current,
31717
+ tabIndex: current ? 0 : -1,
31718
+ "data-current": current ? "" : undefined,
31719
+ onClick: event => {
31720
+ onOpen();
31721
+ onClick?.(event);
31722
+ },
31723
+ onKeyDown: onKeyDown,
31724
+ ...rest,
31725
+ children: jsx(BinderItemContext.Provider, {
31726
+ value: onReportCurrent,
31727
+ children: jsx("span", {
31728
+ className: "navi_binder_tab_label",
31729
+ "data-max-lines": itemMaxLines || undefined,
31730
+ style: itemMaxLines > 1 ? {
31731
+ "--binder-tab-max-lines": itemMaxLines
31732
+ } : undefined,
31733
+ children: label
31734
+ })
31735
+ })
31736
+ });
31737
+ };
31738
+ const BinderOutline = ({
31739
+ rootRef,
31740
+ currentValue,
31741
+ tabsPosition,
31742
+ vertical,
31743
+ tabsFirst
31744
+ }) => {
31745
+ const [drawing, setDrawing] = useState(null);
31746
+ const measure = useCallback(() => {
31747
+ setDrawing(measureDrawing({
31748
+ rootEl: rootRef.current,
31749
+ vertical,
31750
+ tabsFirst
31751
+ }));
31752
+ }, [rootRef, vertical, tabsFirst]);
31753
+ useLayoutEffect(() => {
31754
+ const rootEl = rootRef.current;
31755
+ // The outline is positioned absolutely, so drawing it cannot change any
31756
+ // observed size — the observer settles after one pass.
31757
+ const resizeObserver = new ResizeObserver(measure);
31758
+ resizeObserver.observe(rootEl);
31759
+ for (const tabEl of rootEl.querySelectorAll(".navi_binder_tab")) {
31760
+ resizeObserver.observe(tabEl);
31761
+ }
31762
+ measure();
31763
+ return () => resizeObserver.disconnect();
31764
+ }, [rootRef, measure, currentValue]);
31765
+ if (!drawing) {
31766
+ return jsx("svg", {
31767
+ className: "navi_binder_outline",
31768
+ "aria-hidden": "true"
31769
+ });
31770
+ }
31771
+ return jsx("svg", {
31772
+ className: "navi_binder_outline",
31773
+ "aria-hidden": "true",
31774
+ width: drawing.width,
31775
+ height: drawing.height,
31776
+ children: jsx("path", {
31777
+ d: buildBinderPath({
31778
+ ...drawing.pathParams,
31779
+ tabsPosition
31780
+ }),
31781
+ fill: drawing.background,
31782
+ stroke: drawing.borderColor,
31783
+ "stroke-width": drawing.borderWidth
31784
+ })
31785
+ });
31786
+ };
31787
+
31788
+ // Everything the path needs, read off the DOM rather than taken from props:
31789
+ // the shape has to follow the sizes the layout actually produced — text length,
31790
+ // font, whatever the page contains.
31791
+ const measureDrawing = ({
31792
+ rootEl,
31793
+ vertical,
31794
+ tabsFirst
31795
+ }) => {
31796
+ const tabEls = rootEl.querySelectorAll(".navi_binder_tab");
31797
+ const currentTabEl = rootEl.querySelector(".navi_binder_tab[data-current]");
31798
+ if (!currentTabEl) {
31799
+ return null;
31800
+ }
31801
+ const rootComputedStyle = getComputedStyle(rootEl);
31802
+ const readVar = name => rootComputedStyle.getPropertyValue(name).trim();
31803
+ const borderWidth = parseFloat(readVar("--binder-border-width")) || 0;
31804
+ const outerRadius = parseFloat(readVar("--binder-border-radius")) || 0;
31805
+ const tabRadius = parseFloat(readVar("--binder-tab-border-radius")) || 0;
31806
+
31807
+ // Path space: the main axis runs along the tab row, the cross axis goes from
31808
+ // the tabs toward the page.
31809
+ const mainSizeOf = el => vertical ? el.offsetHeight : el.offsetWidth;
31810
+ const mainStartOf = el => {
31811
+ let start = vertical ? el.offsetTop : el.offsetLeft;
31812
+ let offsetParent = el.offsetParent;
31813
+ while (offsetParent && offsetParent !== rootEl) {
31814
+ start += vertical ? offsetParent.offsetTop : offsetParent.offsetLeft;
31815
+ offsetParent = offsetParent.offsetParent;
31816
+ }
31817
+ return start;
31818
+ };
31819
+ const crossStartOf = el => {
31820
+ let start = vertical ? el.offsetLeft : el.offsetTop;
31821
+ let offsetParent = el.offsetParent;
31822
+ while (offsetParent && offsetParent !== rootEl) {
31823
+ start += vertical ? offsetParent.offsetLeft : offsetParent.offsetTop;
31824
+ offsetParent = offsetParent.offsetParent;
31825
+ }
31826
+ return start;
31827
+ };
31828
+ const crossSizeOf = el => vertical ? el.offsetWidth : el.offsetHeight;
31829
+ const mainSize = vertical ? rootEl.offsetHeight : rootEl.offsetWidth;
31830
+ const crossSize = vertical ? rootEl.offsetWidth : rootEl.offsetHeight;
31831
+ const currentTabCrossStart = crossStartOf(currentTabEl);
31832
+
31833
+ // The junction line lands in the margin every tab keeps on the page side —
31834
+ // right against the tab boxes, so it continues the CSS borders drawn inside
31835
+ // them instead of covering their last row of pixels.
31836
+ let tabRowHeight;
31837
+ let panelHeight;
31838
+ if (tabsFirst) {
31839
+ tabRowHeight = currentTabCrossStart + crossSizeOf(currentTabEl) + borderWidth;
31840
+ panelHeight = crossSize - tabRowHeight;
31841
+ } else {
31842
+ panelHeight = currentTabCrossStart;
31843
+ tabRowHeight = crossSize - panelHeight;
31844
+ }
31845
+ const firstTabEl = tabEls[0];
31846
+ const lastTabEl = tabEls[tabEls.length - 1];
31847
+ return {
31848
+ width: vertical ? crossSize : mainSize,
31849
+ height: vertical ? mainSize : crossSize,
31850
+ borderWidth,
31851
+ borderColor: readVar("--binder-border-color"),
31852
+ background: readVar("--binder-background"),
31853
+ pathParams: {
31854
+ tabX: mainStartOf(currentTabEl),
31855
+ tabWidth: mainSizeOf(currentTabEl),
31856
+ tabRowHeight,
31857
+ panelWidth: mainSize,
31858
+ panelHeight,
31859
+ borderWidth,
31860
+ outerRadius,
31861
+ tabRadius,
31862
+ gapBeforeTabs: mainStartOf(firstTabEl),
31863
+ gapAfterTabs: mainSize - (mainStartOf(lastTabEl) + mainSizeOf(lastTabEl))
31864
+ }
31865
+ };
31866
+ };
31867
+
31868
+ // A bare number in a CSS var stays a bare number and the declaration is
31869
+ // dropped; every length prop here is in pixels when it is not spelled out.
31870
+ const withPixelUnit = value => {
31871
+ if (typeof value === "number") {
31872
+ return `${value}px`;
31873
+ }
31874
+ return value;
31875
+ };
31876
+
31877
+ /**
31878
+ * The room a fixed bar takes from the content, published so whatever scrolls
31879
+ * under it can give that room back.
31880
+ *
31881
+ * There are TWO rooms to give back, and forgetting the second one is the
31882
+ * classic bug:
31883
+ *
31884
+ * - **padding**, so the end of the content can be scrolled out from under the
31885
+ * bar. Without it the last screenful stays covered, unreachable.
31886
+ * - **scroll-padding**, so anything the browser scrolls TO lands in front of
31887
+ * the bar rather than under it. An anchor link, `scrollIntoView()`, a focused
31888
+ * field brought into view, restoring a scroll position — all of them align
31889
+ * the target with the edge of the scrollport, which is behind the bar. The
31890
+ * padding above does not help here: it moves the content, not the place the
31891
+ * browser scrolls the target to.
31892
+ *
31893
+ * Published on <html> as CSS variables rather than applied to some element:
31894
+ * which element scrolls is the app's business, and an app with more than one
31895
+ * would have to fight a component that picked for it. The app either marks its
31896
+ * scrolling area with `data-navi-fixed-bar-space` (the rules below) or reads
31897
+ * the variables itself. `:root` gets the scroll-padding unconditionally,
31898
+ * because the document is the scrollport in the common case and an anchor
31899
+ * landing under a bar is never what anyone wants.
31900
+ *
31901
+ * The variables hold the bar's measured size — see the comment where FixedBar
31902
+ * sets them.
31903
+ */
31904
+
31905
+ const FIXED_BAR_SPACE_CSS = /* css */ `
31906
+ :root {
31907
+ --navi-fixed-bar-space-top: 0px;
31908
+ --navi-fixed-bar-space-bottom: 0px;
31909
+ --navi-fixed-bar-space-left: 0px;
31910
+ --navi-fixed-bar-space-right: 0px;
31911
+
31912
+ scroll-padding-top: var(--navi-fixed-bar-space-top);
31913
+ scroll-padding-right: var(--navi-fixed-bar-space-right);
31914
+ scroll-padding-bottom: var(--navi-fixed-bar-space-bottom);
31915
+ scroll-padding-left: var(--navi-fixed-bar-space-left);
31916
+ }
31917
+
31918
+ /* Put this on whatever scrolls under the bars. */
31919
+ [data-navi-fixed-bar-space] {
31920
+ padding-top: var(--navi-fixed-bar-space-top);
31921
+ padding-right: var(--navi-fixed-bar-space-right);
31922
+ padding-bottom: var(--navi-fixed-bar-space-bottom);
31923
+ padding-left: var(--navi-fixed-bar-space-left);
31924
+
31925
+ scroll-padding-top: var(--navi-fixed-bar-space-top);
31926
+ scroll-padding-right: var(--navi-fixed-bar-space-right);
31927
+ scroll-padding-bottom: var(--navi-fixed-bar-space-bottom);
31928
+ scroll-padding-left: var(--navi-fixed-bar-space-left);
31929
+ }
31930
+ `;
31931
+
31932
+ /**
31933
+ * @param {"top"|"bottom"|"left"|"right"} area
31934
+ * @param {string|null} value - `null` gives the room back to the content.
31935
+ */
31936
+ const setFixedBarSpace = (area, value) => {
31937
+ const property = `--navi-fixed-bar-space-${area}`;
31938
+ const { style } = document.documentElement;
31939
+ if (value === null) {
31940
+ style.removeProperty(property);
31941
+ } else {
31942
+ style.setProperty(property, value);
31943
+ }
31944
+ };
31945
+
31946
+ installImportMetaCssBuild(import.meta);/**
31947
+ * A strip pinned to one edge of the window, and the space it takes back from
31948
+ * the content. What goes in it is none of its business — a nav, a title, a
31949
+ * toolbar, an action.
31950
+ *
31951
+ * The four things it is here to get right, each of which is a trap:
31952
+ *
31953
+ * 1. **`position: fixed`, never sticky.** A sticky element sticks to its
31954
+ * nearest scrolling ancestor, and an app shell almost always has one (an
31955
+ * `overflow` somewhere) — the bar would then stick inside that box and
31956
+ * never to the window. Fixed, centered and bounded by `maxWidth`, it also
31957
+ * stays lined up with the content on a wide screen.
31958
+ * 2. **It gives its space back.** Being fixed it covers the content: without a
31959
+ * reserve the end of a long page stays under it, unreachable. It publishes
31960
+ * what it takes on <html> — see fixed_bar_space.js.
31961
+ * 3. **It reaches under the notch.** `env(safe-area-inset-*)` pushes the
31962
+ * content in while the background keeps running to the edge of the screen.
31963
+ * Note that every `env(safe-area-inset-*)` is 0 unless the page asks for
31964
+ * it: `<meta name="viewport" content="…, viewport-fit=cover">`.
31965
+ * 4. **Its hairline is a box-shadow, not a border.** `width`/`height` is the
31966
+ * bar's content box, so the safe-area padding adds outside it: the size you
31967
+ * asked for is the size the content gets, whatever the device does. A real
31968
+ * border would eat into it; a box-shadow draws the identical line and stays
31969
+ * out of layout.
31970
+ */
31971
+ const css$M = /* css */`
31972
+ @layer navi {
31973
+ :root {
31974
+ --navi-fixed-bar-width: 56px;
31975
+ --navi-fixed-bar-height: 56px;
31976
+ --navi-fixed-bar-max-width: none;
31977
+ --navi-fixed-bar-background: var(--navi-surface-color);
31978
+ --navi-fixed-bar-border-width: 1px;
31979
+ --navi-fixed-bar-border-color: var(--navi-separator-color-default);
31980
+ /* Along the bar only: room so its content never touches the edge of the
31981
+ screen. Across it there is nothing to add — that direction is what the
31982
+ width/height prop names, and padding there would only make the bar
31983
+ thicker than the size asked for. */
31984
+ --navi-fixed-bar-padding: var(--navi-s);
31985
+ }
31986
+ }
31987
+
31988
+ ${FIXED_BAR_SPACE_CSS}
31989
+
31990
+ .navi_fixed_bar {
31991
+ position: fixed;
31992
+ z-index: 1;
31993
+ display: flex;
31994
+ /* content-box against an app that is border-box everywhere else: the size
31995
+ stays bare and the safe-area padding adds OUTSIDE it, so a bar asked for
31996
+ 56px still shows 56px of bar under the notch instead of 56px minus it. */
31997
+ box-sizing: content-box;
31998
+ margin: auto;
31999
+ align-items: center;
32000
+ background: var(--navi-fixed-bar-background);
32001
+
32002
+ &[data-area="top"],
32003
+ &[data-area="bottom"] {
32004
+ right: 0;
32005
+ left: 0;
32006
+ /* No width of its own: pinned to both edges, the used width absorbs the
32007
+ padding instead of being inflated by it — which a content-box
32008
+ "width: 100%" would do. max-width then narrows it and the auto
32009
+ margins re-center it. */
32010
+ max-width: var(--navi-fixed-bar-max-width);
32011
+ height: var(--navi-fixed-bar-height);
32012
+ padding-right: var(--navi-fixed-bar-padding);
32013
+ padding-left: var(--navi-fixed-bar-padding);
32014
+ }
32015
+ &[data-area="left"],
32016
+ &[data-area="right"] {
32017
+ top: 0;
32018
+ bottom: 0;
32019
+ width: var(--navi-fixed-bar-width);
32020
+ padding-top: var(--navi-fixed-bar-padding);
32021
+ padding-bottom: var(--navi-fixed-bar-padding);
32022
+ flex-direction: column;
32023
+ }
32024
+
32025
+ /* The inset on this edge alone: the one the bar is pinned to. */
32026
+ &[data-area="top"] {
32027
+ top: 0;
32028
+ padding-top: env(safe-area-inset-top);
32029
+ box-shadow: 0 var(--navi-fixed-bar-border-width) 0
32030
+ var(--navi-fixed-bar-border-color);
32031
+ }
32032
+ &[data-area="bottom"] {
32033
+ bottom: 0;
32034
+ padding-bottom: env(safe-area-inset-bottom);
32035
+ box-shadow: 0 calc(-1 * var(--navi-fixed-bar-border-width)) 0
32036
+ var(--navi-fixed-bar-border-color);
32037
+ }
32038
+ &[data-area="left"] {
32039
+ left: 0;
32040
+ padding-left: env(safe-area-inset-left);
32041
+ box-shadow: var(--navi-fixed-bar-border-width) 0 0
32042
+ var(--navi-fixed-bar-border-color);
32043
+ }
32044
+ &[data-area="right"] {
32045
+ right: 0;
32046
+ padding-right: env(safe-area-inset-right);
32047
+ box-shadow: calc(-1 * var(--navi-fixed-bar-border-width)) 0 0
32048
+ var(--navi-fixed-bar-border-color);
32049
+ }
32050
+ &[data-border="none"] {
32051
+ box-shadow: none;
32052
+ }
32053
+ }
32054
+ `;
32055
+ const FixedBarStyleCSSVars = {
32056
+ maxWidth: "--navi-fixed-bar-max-width",
32057
+ padding: "--navi-fixed-bar-padding",
32058
+ background: "--navi-fixed-bar-background",
32059
+ borderWidth: "--navi-fixed-bar-border-width",
32060
+ borderColor: "--navi-fixed-bar-border-color"
32061
+ };
32062
+
32063
+ /**
32064
+ * @type {import("ignore:preact").FunctionComponent<{
32065
+ * area?: "top"|"bottom"|"left"|"right",
32066
+ * width?: string|number,
32067
+ * height?: string|number,
32068
+ * maxWidth?: string|number,
32069
+ * padding?: string|number,
32070
+ * background?: string,
32071
+ * border?: boolean,
32072
+ * borderWidth?: string|number,
32073
+ * borderColor?: string,
32074
+ * }>}
32075
+ * @param {"top"|"bottom"|"left"|"right"} [props.area="top"] - Which edge of
32076
+ * the window it is pinned to.
32077
+ * @param {string|number} [props.height] - For a bar on the top or bottom
32078
+ * @param {string|number} [props.width] - …and for one on a side. The safe-area
32079
+ * inset is NOT included in it: the inset is added outside, and the content
32080
+ * keeps the size asked for.
32081
+ * @param {boolean} [props.border=true] - The hairline on the content side.
32082
+ * Drawn with a box-shadow so it never eats into the size; give it a
32083
+ * `borderWidth`/`borderColor`, or `border={false}` for none.
32084
+ * @param {string|number} [props.maxWidth] - Keeps the bar lined up with a
32085
+ * content column narrower than the window (it stays centered).
32086
+ */
32087
+ const FixedBar = ({
32088
+ children,
32089
+ area = "top",
32090
+ border = true,
32091
+ ...props
32092
+ }) => {
32093
+ import.meta.css = [css$M, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
32094
+ const defaultRef = useRef();
32095
+ props.ref = props.ref || defaultRef;
32096
+ // Whichever of width/height crosses the edge the bar sits on is what the
32097
+ // content has to be given back — and the bar's border box already IS that:
32098
+ // the size it was given, plus the safe-area padding added outside it.
32099
+ // Measured rather than rebuilt as a calc() expression, so a size coming from
32100
+ // anywhere — a prop, a theme variable, the content itself — is reserved just
32101
+ // the same, and each `env()` inset stays the browser's business alone.
32102
+ const vertical = area === "left" || area === "right";
32103
+ const {
32104
+ ref
32105
+ } = props;
32106
+ useLayoutEffect(() => {
32107
+ const barElement = ref.current;
32108
+ if (!barElement) {
32109
+ return undefined;
32110
+ }
32111
+ const {
32112
+ width,
32113
+ height
32114
+ } = barElement.getBoundingClientRect();
32115
+ setFixedBarSpace(area, `${vertical ? width : height}px`);
32116
+ return () => {
32117
+ setFixedBarSpace(area, null);
32118
+ };
32119
+ }, [area]);
32120
+ return jsx(Box, {
32121
+ baseClassName: "navi_fixed_bar",
32122
+ "data-area": area,
32123
+ "data-border": border ? undefined : "none",
32124
+ ...props,
32125
+ styleCSSVars: FixedBarStyleCSSVars,
32126
+ children: children
32127
+ });
32128
+ };
32129
+
30797
32130
  const detectMac = () => {
30798
32131
  // Modern way using User-Agent Client Hints API
30799
32132
  if (window.navigator.userAgentData) {
@@ -47001,8 +48334,41 @@ installImportMetaCssBuild(import.meta);const css$t = /* css */`
47001
48334
  }
47002
48335
  .navi_badge {
47003
48336
  --font-size: 0.7em;
47004
- --padding-x: 0.8em;
47005
- --padding-y: 0.4em;
48337
+ --badge-padding-x-default: 0.8em;
48338
+ --badge-padding-y-default: 0.4em;
48339
+
48340
+ /* Each side resolves the most specific value it was given, from the side
48341
+ itself down to the axis, the shorthand, then the default. Resolved once
48342
+ here because the close button below reuses these to cancel the badge
48343
+ padding on the edge it sits on. */
48344
+ --x-badge-padding-top: var(
48345
+ --badge-padding-top,
48346
+ var(
48347
+ --badge-padding-y,
48348
+ var(--badge-padding, var(--badge-padding-y-default))
48349
+ )
48350
+ );
48351
+ --x-badge-padding-right: var(
48352
+ --badge-padding-right,
48353
+ var(
48354
+ --badge-padding-x,
48355
+ var(--badge-padding, var(--badge-padding-x-default))
48356
+ )
48357
+ );
48358
+ --x-badge-padding-bottom: var(
48359
+ --badge-padding-bottom,
48360
+ var(
48361
+ --badge-padding-y,
48362
+ var(--badge-padding, var(--badge-padding-y-default))
48363
+ )
48364
+ );
48365
+ --x-badge-padding-left: var(
48366
+ --badge-padding-left,
48367
+ var(
48368
+ --badge-padding-x,
48369
+ var(--badge-padding, var(--badge-padding-x-default))
48370
+ )
48371
+ );
47006
48372
 
47007
48373
  --x-background: var(--background, light-dark(#e0e0e0, #3a3a3a));
47008
48374
  --x-background-color: var(--background-color, var(--x-background));
@@ -47014,10 +48380,10 @@ installImportMetaCssBuild(import.meta);const css$t = /* css */`
47014
48380
  position: relative;
47015
48381
  display: inline;
47016
48382
  max-width: 200px;
47017
- padding-top: var(--padding-y);
47018
- padding-right: var(--padding-x);
47019
- padding-bottom: var(--padding-y);
47020
- padding-left: var(--padding-x);
48383
+ padding-top: var(--x-badge-padding-top);
48384
+ padding-right: var(--x-badge-padding-right);
48385
+ padding-bottom: var(--x-badge-padding-bottom);
48386
+ padding-left: var(--x-badge-padding-left);
47021
48387
  align-items: stretch;
47022
48388
  color: var(--x-color);
47023
48389
  font-size: var(--font-size);
@@ -47041,25 +48407,25 @@ installImportMetaCssBuild(import.meta);const css$t = /* css */`
47041
48407
 
47042
48408
  [role="button"] {
47043
48409
  display: inline-flex;
47044
- margin-top: calc(-1 * var(--padding-y));
47045
- margin-bottom: calc(-1 * var(--padding-y));
47046
- padding-top: var(--padding-y);
47047
- padding-right: calc(var(--padding-x) / 2);
47048
- padding-bottom: var(--padding-y);
47049
- padding-left: calc(var(--padding-x) / 2);
48410
+ margin-top: calc(-1 * var(--x-badge-padding-top));
48411
+ margin-bottom: calc(-1 * var(--x-badge-padding-bottom));
48412
+ padding-top: var(--x-badge-padding-top);
48413
+ padding-right: calc(var(--x-badge-padding-right) / 2);
48414
+ padding-bottom: var(--x-badge-padding-bottom);
48415
+ padding-left: calc(var(--x-badge-padding-left) / 2);
47050
48416
  align-items: center;
47051
48417
  cursor: pointer;
47052
48418
  pointer-events: auto;
47053
48419
  user-select: none;
47054
48420
 
47055
48421
  &:first-child {
47056
- margin-left: calc(-1 * var(--padding-x));
48422
+ margin-left: calc(-1 * var(--x-badge-padding-left));
47057
48423
  border-top-left-radius: inherit;
47058
48424
  border-bottom-left-radius: inherit;
47059
48425
  }
47060
48426
 
47061
48427
  &:last-child {
47062
- margin-right: calc(-1 * var(--padding-x));
48428
+ margin-right: calc(-1 * var(--x-badge-padding-right));
47063
48429
  border-top-right-radius: inherit;
47064
48430
  border-bottom-right-radius: inherit;
47065
48431
  }
@@ -47087,16 +48453,21 @@ const Badge = ({
47087
48453
  bold: true,
47088
48454
  maxLines: 1,
47089
48455
  ...props,
47090
- styleCSSVars: BadgeStyleCSSVars$1,
48456
+ styleCSSVars: BadgeStyleCSSVars,
47091
48457
  spacing: jsx("span", {}),
47092
48458
  children: children
47093
48459
  });
47094
48460
  };
47095
- const BadgeStyleCSSVars$1 = {
48461
+ const BadgeStyleCSSVars = {
47096
48462
  borderWidth: "--border-width",
47097
48463
  borderRadius: "--border-radius",
47098
- paddingRight: "--padding-right",
47099
- paddingLeft: "--padding-left",
48464
+ padding: "--badge-padding",
48465
+ paddingX: "--badge-padding-x",
48466
+ paddingY: "--badge-padding-y",
48467
+ paddingTop: "--badge-padding-top",
48468
+ paddingRight: "--badge-padding-right",
48469
+ paddingBottom: "--badge-padding-bottom",
48470
+ paddingLeft: "--badge-padding-left",
47100
48471
  backgroundColor: "--background-color",
47101
48472
  background: "--background",
47102
48473
  borderColor: "--border-color",
@@ -56715,8 +58086,40 @@ installImportMetaCssBuild(import.meta);const css$b = /* css */`
56715
58086
  --x-background-color: var(--background-color, var(--x-background));
56716
58087
  --x-color-contrasting: var(--navi-color-white);
56717
58088
  --x-color: var(--color, var(--x-color-contrasting));
56718
- --padding-x: 0.5em;
56719
- --padding-y: 0.2em;
58089
+ --badge-count-padding-x-default: 0.5em;
58090
+ --badge-count-padding-y-default: 0.2em;
58091
+
58092
+ /* Each side resolves the most specific value it was given, from the side
58093
+ itself down to the axis, the shorthand, then the default. */
58094
+ --x-badge-count-padding-top: var(
58095
+ --badge-count-padding-top,
58096
+ var(
58097
+ --badge-count-padding-y,
58098
+ var(--badge-count-padding, var(--badge-count-padding-y-default))
58099
+ )
58100
+ );
58101
+ --x-badge-count-padding-right: var(
58102
+ --badge-count-padding-right,
58103
+ var(
58104
+ --badge-count-padding-x,
58105
+ var(--badge-count-padding, var(--badge-count-padding-x-default))
58106
+ )
58107
+ );
58108
+ --x-badge-count-padding-bottom: var(
58109
+ --badge-count-padding-bottom,
58110
+ var(
58111
+ --badge-count-padding-y,
58112
+ var(--badge-count-padding, var(--badge-count-padding-y-default))
58113
+ )
58114
+ );
58115
+ --x-badge-count-padding-left: var(
58116
+ --badge-count-padding-left,
58117
+ var(
58118
+ --badge-count-padding-x,
58119
+ var(--badge-count-padding, var(--badge-count-padding-x-default))
58120
+ )
58121
+ );
58122
+
56720
58123
  position: relative;
56721
58124
  color: var(--x-color);
56722
58125
  font-size: var(--font-size);
@@ -56740,10 +58143,10 @@ installImportMetaCssBuild(import.meta);const css$b = /* css */`
56740
58143
 
56741
58144
  /* Ellipse */
56742
58145
  &[data-ellipse] {
56743
- padding-top: var(--padding-y);
56744
- padding-right: var(--padding-x);
56745
- padding-bottom: var(--padding-y);
56746
- padding-left: var(--padding-x);
58146
+ padding-top: var(--x-badge-count-padding-top);
58147
+ padding-right: var(--x-badge-count-padding-right);
58148
+ padding-bottom: var(--x-badge-count-padding-bottom);
58149
+ padding-left: var(--x-badge-count-padding-left);
56747
58150
  line-height: normal;
56748
58151
  background: var(--x-background);
56749
58152
  background-color: var(--x-background-color);
@@ -56751,29 +58154,48 @@ installImportMetaCssBuild(import.meta);const css$b = /* css */`
56751
58154
 
56752
58155
  /* For ellipse + single char force the circle aspect as it's prettier */
56753
58156
  &[data-single-char] {
58157
+ /* The digit sits in a square content box that the radius rounds into a
58158
+ circle, so no padding is needed to obtain the shape. Sizing is
58159
+ content-box: a padding prop then grows the square from the outside —
58160
+ a bigger circle, or a pill on one axis — instead of eating into it
58161
+ and pushing the digit out. */
58162
+ --badge-count-padding-x-default: 0;
58163
+ --badge-count-padding-y-default: 0;
58164
+
56754
58165
  display: inline-block;
56755
- aspect-ratio: 1/1;
58166
+ box-sizing: content-box;
58167
+ width: 1.6em;
56756
58168
  height: 1.6em;
56757
- padding: 0;
56758
58169
  text-align: center;
56759
58170
  line-height: 1.6em;
58171
+ /* Larger than any half-height it can reach, so the shape stays fully
58172
+ round whatever the padding adds. */
58173
+ border-radius: 100em;
56760
58174
  }
56761
58175
  }
56762
58176
 
56763
58177
  /* Circle */
56764
58178
  &[data-circle] {
56765
58179
  --x-number-font-size: var(--font-size);
58180
+ /* Same as the single char ellipse: the radius comes from the number of
58181
+ characters, padding grows it from the outside. */
58182
+ --badge-count-padding-x-default: 0;
58183
+ --badge-count-padding-y-default: 0;
56766
58184
 
56767
58185
  display: inline-flex;
56768
58186
  box-sizing: content-box;
56769
58187
  aspect-ratio: 1/1;
56770
58188
  width: var(--x-radius);
56771
58189
  height: var(--x-radius);
58190
+ padding-top: var(--x-badge-count-padding-top);
58191
+ padding-right: var(--x-badge-count-padding-right);
58192
+ padding-bottom: var(--x-badge-count-padding-bottom);
58193
+ padding-left: var(--x-badge-count-padding-left);
56772
58194
  align-items: center;
56773
58195
  justify-content: center;
56774
58196
  background: var(--x-background);
56775
58197
  background-color: var(--x-background-color);
56776
- border-radius: 50%;
58198
+ border-radius: 100em;
56777
58199
 
56778
58200
  &[data-single-char] {
56779
58201
  --x-radius: 1.6em;
@@ -56875,11 +58297,16 @@ const BadgeCount = ({
56875
58297
  })
56876
58298
  });
56877
58299
  };
56878
- const BadgeStyleCSSVars = {
58300
+ const BadgeCountStyleCSSVars = {
56879
58301
  borderWidth: "--border-width",
56880
58302
  borderRadius: "--border-radius",
56881
- paddingRight: "--padding-right",
56882
- paddingLeft: "--padding-left",
58303
+ padding: "--badge-count-padding",
58304
+ paddingX: "--badge-count-padding-x",
58305
+ paddingY: "--badge-count-padding-y",
58306
+ paddingTop: "--badge-count-padding-top",
58307
+ paddingRight: "--badge-count-padding-right",
58308
+ paddingBottom: "--badge-count-padding-bottom",
58309
+ paddingLeft: "--badge-count-padding-left",
56883
58310
  backgroundColor: "--background-color",
56884
58311
  background: "--background",
56885
58312
  borderColor: "--border-color",
@@ -56918,7 +58345,7 @@ const BadgeCountEllipse = ({
56918
58345
  "data-loading": loading ? "" : undefined,
56919
58346
  "data-single-char": charCount === 1 ? "" : undefined,
56920
58347
  ...props,
56921
- styleCSSVars: BadgeStyleCSSVars,
58348
+ styleCSSVars: BadgeCountStyleCSSVars,
56922
58349
  spacing: "pre",
56923
58350
  children: loading ? jsx(Icon, {
56924
58351
  children: jsx(LoadingDotsSvg, {})
@@ -56944,7 +58371,7 @@ const BadgeCountCircle = ({
56944
58371
  "data-four-chars": charCount === 4 ? "" : undefined,
56945
58372
  "data-value-overflow": hasOverflow ? "" : undefined,
56946
58373
  ...props,
56947
- styleCSSVars: BadgeStyleCSSVars,
58374
+ styleCSSVars: BadgeCountStyleCSSVars,
56948
58375
  spacing: "pre",
56949
58376
  children: loading ? jsx(Icon, {
56950
58377
  children: jsx(LoadingDotsSvg, {})
@@ -58882,5 +60309,5 @@ const UserSvg = () => jsx("svg", {
58882
60309
  })
58883
60310
  });
58884
60311
 
58885
- 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 };
60312
+ 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 };
58886
60313
  //# sourceMappingURL=jsenv_navi.js.map