@jsenv/navi 0.29.91 → 0.29.92

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.
@@ -342,11 +342,11 @@ installImportMetaCssBuild(import.meta);/**
342
342
  *
343
343
  * Two decisions worth knowing before reading:
344
344
  *
345
- * - **navi places the target itself, every time.** Where the browser does
346
- * answer a fragment it puts the element against the top edge, where it reads
347
- * as the first thing on the page rather than as the one that was pointed at;
348
- * the alignment below is applied after, so one rule holds whether the target
349
- * was there all along or arrived late. A page with nothing to scroll simply
345
+ * - **navi places the target itself, every time.** The scroll the browser
346
+ * would have done target against the top edge, instantly is applied
347
+ * here, after layout, so one rule holds whether the target was there all
348
+ * along or arrived late; `setUrlTargetOptions` lets an app pick another
349
+ * alignment or a smooth behavior. A page with nothing to scroll simply
350
350
  * does not move, which is the whole of the "the list already fits on screen"
351
351
  * case — the transient mark alone then says which one was meant.
352
352
  *
@@ -381,8 +381,8 @@ const css$12 = /* css */`
381
381
  `;
382
382
  import.meta.css = [css$12, "@jsenv/navi/src/nav/url_target/url_target.js"];
383
383
  let urlTargetOptions = {
384
- block: "center",
385
- behavior: "smooth",
384
+ block: "start",
385
+ behavior: "instant",
386
386
  markDuration: 2000,
387
387
  graceAfterIdle: 1000,
388
388
  maxWait: 10_000
@@ -392,12 +392,12 @@ let urlTargetOptions = {
392
392
  * Adjusts how navi answers the element designated by the URL hash.
393
393
  *
394
394
  * @param {object} options
395
- * @param {"start"|"center"|"end"|"nearest"} [options.block="center"]
396
- * Vertical alignment of the scroll. "center" by default: an element stuck to
397
- * the top of the screen reads as the first one of the page rather than as the
398
- * one that was pointed at.
399
- * @param {ScrollBehavior} [options.behavior="smooth"]
400
- * Overridden with "instant" under `prefers-reduced-motion: reduce`.
395
+ * @param {"start"|"center"|"end"|"nearest"} [options.block="start"]
396
+ * Vertical alignment of the scroll. "start" by default the alignment the
397
+ * browser itself uses when it answers a fragment.
398
+ * @param {ScrollBehavior} [options.behavior="instant"]
399
+ * "instant" by default, like the browser. When set to "smooth", it is
400
+ * overridden with "instant" under `prefers-reduced-motion: reduce`.
401
401
  * @param {number} [options.markDuration=2000]
402
402
  * How long, in ms, the element carries `data-url-target`. Published to CSS as
403
403
  * `--navi-url-target-duration`.
@@ -23905,7 +23905,15 @@ const useUIStateController = (
23905
23905
  // Uses the in-memory registry instead of DOM queries so this works even
23906
23906
  // when sibling items are virtualized (not in the DOM).
23907
23907
  // Form scoping is preserved by comparing parentUIStateController references.
23908
- if (isRadio && newUIState && controller.name && !controlProxyFor) {
23908
+ // Checked, not truthy: a radio holding `false` (two rows asking a
23909
+ // yes/no) is as checked as one holding a name, and the row that was
23910
+ // checked before has to let go all the same.
23911
+ if (
23912
+ isRadio &&
23913
+ newUIState !== undefined &&
23914
+ controller.name &&
23915
+ !controlProxyFor
23916
+ ) {
23909
23917
  const siblings = getRadioSiblings(controller);
23910
23918
  if (siblings) {
23911
23919
  const siblingUncheckEvent = new CustomEvent(
@@ -24485,6 +24493,7 @@ const useUIGroupStateController = (
24485
24493
  childControlFilter,
24486
24494
  aggregateChildStates,
24487
24495
  distributeChildUIState,
24496
+ distributeChildStates,
24488
24497
  wantRequesterButtonState,
24489
24498
  uiActionInternal,
24490
24499
  allowCapture = false,
@@ -24501,7 +24510,7 @@ const useUIGroupStateController = (
24501
24510
  // whatever IT says — a "HH:MM", an ISO duration — and the shape checks in
24502
24511
  // setUIState below are about the default shape, not about that one.
24503
24512
  const stateShapeIsTheDefaultOne =
24504
- !aggregateChildStates && !distributeChildUIState;
24513
+ !aggregateChildStates && !distributeChildUIState && !distributeChildStates;
24505
24514
  const defaults = GROUP_DEFAULTS[controlType] ?? GROUP_DEFAULTS[stateType];
24506
24515
  const resolvedChildControlFilter =
24507
24516
  childControlFilter ?? defaults?.childControlFilter ?? null;
@@ -24509,6 +24518,12 @@ const useUIGroupStateController = (
24509
24518
  aggregateChildStates ?? defaults?.aggregateChildStates;
24510
24519
  const resolvedDistributeChildUIState =
24511
24520
  distributeChildUIState ?? defaults?.distributeChildUIState;
24521
+ // The plural half of the pair: `aggregateChildStates` already sees all the
24522
+ // children at once, and a group whose value is not one key per child usually
24523
+ // needs the same view on the way down — which of four seats each player takes
24524
+ // cannot be decided one seat at a time. Given both, the plural one wins.
24525
+ const resolvedDistributeChildStates =
24526
+ distributeChildStates ?? defaults?.distributeChildStates;
24512
24527
  if (
24513
24528
  typeof resolvedAggregateChildStates !== "function" ||
24514
24529
  typeof resolvedDistributeChildUIState !== "function"
@@ -24763,6 +24778,48 @@ const useUIGroupStateController = (
24763
24778
  // Where the group puts a value on ONE child: the only place that knows
24764
24779
  // what each child gets, and the only one that sees a child it cannot
24765
24780
  // place — see warnChildAnswersForItself.
24781
+ // One pass over every child, which is what a plural distribute needs:
24782
+ // it is asked once, sees the whole group, and answers for all of them.
24783
+ placeChildrenUIState: (groupUIState, e) => {
24784
+ if (!resolvedDistributeChildStates) {
24785
+ for (const childUIStateController of childUIStateControllerArray) {
24786
+ controller.placeChildUIState(
24787
+ childUIStateController,
24788
+ groupUIState,
24789
+ e,
24790
+ );
24791
+ }
24792
+ return;
24793
+ }
24794
+ const monitoredChildren = childUIStateControllerArray.filter(
24795
+ shouldPropagateStateToChild,
24796
+ );
24797
+ const stateByChild = resolvedDistributeChildStates(
24798
+ groupUIState,
24799
+ monitoredChildren,
24800
+ );
24801
+ if (!stateByChild) {
24802
+ return;
24803
+ }
24804
+ for (const childUIStateController of monitoredChildren) {
24805
+ if (!stateByChild.has(childUIStateController)) {
24806
+ // Not named by the answer: left where it is, the way
24807
+ // CANNOT_DERIVE leaves a child a per-child distribute says
24808
+ // nothing about.
24809
+ continue;
24810
+ }
24811
+ if (
24812
+ childUIStateController.hasStateProp &&
24813
+ !childUIStateController.props.signal
24814
+ ) {
24815
+ continue;
24816
+ }
24817
+ childUIStateController.setUIState(
24818
+ stateByChild.get(childUIStateController),
24819
+ e,
24820
+ );
24821
+ }
24822
+ },
24766
24823
  placeChildUIState: (childUIStateController, groupUIState, e) => {
24767
24824
  if (!shouldPropagateStateToChild(childUIStateController)) {
24768
24825
  return;
@@ -24824,13 +24881,7 @@ const useUIGroupStateController = (
24824
24881
  detail: {},
24825
24882
  });
24826
24883
  chainEvent(propagateDownEvent, e);
24827
- for (const childUIStateController of childUIStateControllerArray) {
24828
- controller.placeChildUIState(
24829
- childUIStateController,
24830
- newUIState,
24831
- propagateDownEvent,
24832
- );
24833
- }
24884
+ controller.placeChildrenUIState(newUIState, propagateDownEvent);
24834
24885
  const groupUIState = aggregateGroupUIState(newUIState);
24835
24886
  if (e.type === "initial_state_push") {
24836
24887
  controller.syncInternalState(groupUIState);
@@ -24905,11 +24956,21 @@ const useUIGroupStateController = (
24905
24956
  const initialEvent = new CustomEvent("initial_state_push", {
24906
24957
  detail: {},
24907
24958
  });
24908
- controller.placeChildUIState(
24909
- childUIStateController,
24910
- stateToPlaceChildFrom,
24911
- initialEvent,
24912
- );
24959
+ if (resolvedDistributeChildStates) {
24960
+ // A group answering for all its children at once has to be asked
24961
+ // again now that there is one more: what each of them shows may
24962
+ // depend on who else is there.
24963
+ controller.placeChildrenUIState(
24964
+ stateToPlaceChildFrom,
24965
+ initialEvent,
24966
+ );
24967
+ } else {
24968
+ controller.placeChildUIState(
24969
+ childUIStateController,
24970
+ stateToPlaceChildFrom,
24971
+ initialEvent,
24972
+ );
24973
+ }
24913
24974
  }
24914
24975
  onChange(new CustomEvent(`${childControlType}_mount`), {
24915
24976
  notifyExternal: "silent",
@@ -25084,13 +25145,7 @@ const useUIGroupStateController = (
25084
25145
  "propagate_down_set_ui_state",
25085
25146
  { detail: {} },
25086
25147
  );
25087
- for (const childUIStateController of childUIStateControllerArray) {
25088
- controller.placeChildUIState(
25089
- childUIStateController,
25090
- groupUIState,
25091
- propagateDownEvent,
25092
- );
25093
- }
25148
+ controller.placeChildrenUIState(groupUIState, propagateDownEvent);
25094
25149
  controller.syncInternalState(groupUIState);
25095
25150
  };
25096
25151
  if (
@@ -25381,6 +25436,7 @@ const useUIFacadeStateController = (props, realUIStateController) => {
25381
25436
  return {
25382
25437
  controller: facadeUIStateController,
25383
25438
  realUIStateController,
25439
+ props,
25384
25440
  };
25385
25441
  },
25386
25442
  // ── update: runs every render after the first ─────────────────────────
@@ -25394,6 +25450,7 @@ const useUIFacadeStateController = (props, realUIStateController) => {
25394
25450
 
25395
25451
  return {
25396
25452
  realUIStateController,
25453
+ props,
25397
25454
  };
25398
25455
  },
25399
25456
  );
@@ -26444,7 +26501,11 @@ const createControlInfo = (props, {
26444
26501
  if (typeProp === "checkbox" || typeProp === "radio") {
26445
26502
  statePropName = "checked";
26446
26503
  defaultStatePropName = "defaultChecked";
26447
- value = props.value || "on";
26504
+ // "on" is what HTML sends for a checkbox given no value of its own — the
26505
+ // default of an ABSENT prop, not of a falsy one: `value={false}` and
26506
+ // `value={0}` are values, and two rows holding true and false are how a
26507
+ // list asks a yes/no question.
26508
+ value = props.value === undefined ? "on" : props.value;
26448
26509
  signalHoldsChecked = true;
26449
26510
  if (signal) {
26450
26511
  if (props.defaultChecked) {
@@ -26629,6 +26690,7 @@ const useControlgroupProps = (props, {
26629
26690
  childControlFilter,
26630
26691
  aggregateChildStates,
26631
26692
  distributeChildUIState,
26693
+ distributeChildStates,
26632
26694
  wantRequesterButtonState,
26633
26695
  uiActionInternal,
26634
26696
  allowCapture = false,
@@ -26642,6 +26704,7 @@ const useControlgroupProps = (props, {
26642
26704
  childControlFilter,
26643
26705
  aggregateChildStates,
26644
26706
  distributeChildUIState,
26707
+ distributeChildStates,
26645
26708
  wantRequesterButtonState,
26646
26709
  uiActionInternal,
26647
26710
  allowCapture,
@@ -45833,7 +45896,6 @@ const useFocusGroup = (
45833
45896
  };
45834
45897
 
45835
45898
  installImportMetaCssBuild(import.meta);const rightArrowPath = "M680-480L360-160l-80-80 240-240-240-240 80-80 320 320z";
45836
- const downArrowPath = "M480-280L160-600l80-80 240 240 240-240 80 80-320 320z";
45837
45899
  const css$L = /* css */`
45838
45900
  .navi_summary_marker {
45839
45901
  width: 1em;
@@ -45858,22 +45920,31 @@ const css$L = /* css */`
45858
45920
  }
45859
45921
  }
45860
45922
 
45861
- .navi_summary_marker_arrow {
45862
- opacity: 1;
45863
- transition: opacity 0.3s ease-in-out;
45864
- animation-duration: 0.3s;
45865
- animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
45866
- animation-fill-mode: forwards;
45923
+ /* One chevron, rotated: the transition only ever plays on a direction
45924
+ change, so the first paint shows the resting direction with no
45925
+ movement. */
45926
+ .navi_summary_marker_arrow_group {
45927
+ transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
45867
45928
 
45868
- &[data-animation-target="down"] {
45869
- animation-name: morph-to-down;
45929
+ &[data-direction="right"] {
45930
+ transform: rotate(0deg);
45870
45931
  }
45871
-
45872
- &[data-animation-target="right"] {
45873
- animation-name: morph-to-right;
45932
+ &[data-direction="down"] {
45933
+ transform: rotate(90deg);
45934
+ }
45935
+ &[data-direction="up"] {
45936
+ transform: rotate(-90deg);
45937
+ }
45938
+ &[data-direction="left"] {
45939
+ transform: rotate(180deg);
45874
45940
  }
45875
45941
  }
45876
45942
 
45943
+ .navi_summary_marker_arrow {
45944
+ opacity: 1;
45945
+ transition: opacity 0.3s ease-in-out;
45946
+ }
45947
+
45877
45948
  &[data-loading] {
45878
45949
  .navi_summary_marker_loading_container {
45879
45950
  transform: scale(1);
@@ -45898,39 +45969,25 @@ const css$L = /* css */`
45898
45969
  stroke-dashoffset: -2010;
45899
45970
  }
45900
45971
  }
45901
- @keyframes morph-to-down {
45902
- from {
45903
- d: path("${rightArrowPath}");
45904
- }
45905
- to {
45906
- d: path("${downArrowPath}");
45907
- }
45908
- }
45909
- @keyframes morph-to-right {
45910
- from {
45911
- d: path("${downArrowPath}");
45912
- }
45913
- to {
45914
- d: path("${rightArrowPath}");
45915
- }
45916
- }
45917
45972
  `;
45973
+
45974
+ /**
45975
+ * @type {import("ignore:preact").FunctionComponent<{
45976
+ * open?: boolean,
45977
+ * loading?: boolean,
45978
+ * openDirection?: "down" | "up" | "left",
45979
+ * }>}
45980
+ * @param openDirection - Where the chevron points while open; closed always
45981
+ * points right. "down" fits content revealed below (the <details> shape),
45982
+ * "up" content revealed above, "left" content revealed beside.
45983
+ */
45918
45984
  const SummaryMarker = ({
45919
45985
  open,
45920
- loading
45986
+ loading,
45987
+ openDirection = "down"
45921
45988
  }) => {
45922
45989
  import.meta.css = [css$L, "@jsenv/navi/src/control/details/summary_marker.jsx"];
45923
45990
  const showLoading = useDebounceTrue(loading, 300);
45924
- const mountedRef = useRef(false);
45925
- const prevOpenRef = useRef(open);
45926
- useLayoutEffect(() => {
45927
- mountedRef.current = true;
45928
- return () => {
45929
- mountedRef.current = false;
45930
- };
45931
- }, []);
45932
- const shouldAnimate = mountedRef.current && prevOpenRef.current !== open;
45933
- prevOpenRef.current = open;
45934
45991
  return jsx("span", {
45935
45992
  className: "navi_summary_marker",
45936
45993
  "data-loading": showLoading ? "" : undefined,
@@ -45961,12 +46018,13 @@ const SummaryMarker = ({
45961
46018
  strokeDasharray: "503 1507"
45962
46019
  })]
45963
46020
  }), jsx("g", {
46021
+ className: "navi_summary_marker_arrow_group",
46022
+ "data-direction": open ? openDirection : "right",
45964
46023
  "transform-origin": "480px -480px",
45965
46024
  children: jsx("path", {
45966
46025
  className: "navi_summary_marker_arrow",
45967
46026
  fill: "currentColor",
45968
- "data-animation-target": shouldAnimate ? open ? "down" : "right" : undefined,
45969
- d: open ? downArrowPath : rightArrowPath
46027
+ d: rightArrowPath
45970
46028
  })
45971
46029
  })]
45972
46030
  })
@@ -46196,21 +46254,32 @@ installImportMetaCssBuild(import.meta);/**
46196
46254
  * </Expandable>
46197
46255
  *
46198
46256
  * Content after UI expands below (the <details> shape), Content before UI
46199
- * expands above; `layout="column"` puts the parts side by side instead, the
46200
- * content then expanding horizontally. The common shape has a shorthand:
46201
- * `ui` prop + children as content.
46202
- * - the UI part accepts any markup (buttons, links, fields) only the small
46203
- * marker is a real <button>, carrying the aria for the whole row
46204
- * (aria-expanded/aria-controls, labelled by the UI part), so nested
46205
- * interactive content never ends up inside an interactive element.
46257
+ * expands above; `layout="column"` puts the parts side by side (sharing
46258
+ * their height), the content then expanding horizontally. The marker
46259
+ * chevron follows: it points right while closed and toward where the
46260
+ * content went while open (down, up, or left). The common shape has a
46261
+ * shorthand: `ui` prop + children as content.
46262
+ * - the UI part is the focusable toggle itself (role button, Space/Enter,
46263
+ * arrow keys) and accepts any markup: controls inside it keep their own
46264
+ * behavior, the marker is purely decorative.
46265
+ *
46266
+ * Reach for it knowingly: expanding in-flow SHIFTS the layout — everything
46267
+ * below (or beside) moves when it opens. A Popover, Dialog, Picker or Callout
46268
+ * answers the same click on its own layer, moving nothing, which is usually
46269
+ * the better UX: a layout that stays where it is reads and operates better —
46270
+ * all the more on small screens, mobile first of all, where the shift can
46271
+ * push most of the page away. Expandable is for content that genuinely
46272
+ * belongs in the flow (a tree, a changelog, a settings group read top to
46273
+ * bottom).
46206
46274
  *
46207
46275
  * What <details> gives for free is rebuilt here:
46208
46276
  * - a "toggle" event (a real ToggleEvent when the browser has it) dispatched on
46209
46277
  * the root whenever the state actually changes — but never on mount, unlike
46210
46278
  * the native one (see the workaround comment in details.jsx);
46211
46279
  * - `--navi-toggle`/`--navi-open`/`--navi-close` commands work against it: the
46212
- * root carries `aria-expanded` (what the command system reads) and answers
46213
- * the `navi_command`/`navi_request_open`/`navi_request_close` events.
46280
+ * root and the UI part carry `aria-expanded` (what the command system reads)
46281
+ * and answer the `navi_command`/`navi_request_open`/`navi_request_close`
46282
+ * events.
46214
46283
  *
46215
46284
  * Content is not built until the first expansion and stays built afterwards —
46216
46285
  * same policy, same prop names as popups (see popup_content_mount.js):
@@ -46218,12 +46287,15 @@ installImportMetaCssBuild(import.meta);/**
46218
46287
  * once the collapse settles (so a closing animation still plays on real
46219
46288
  * content).
46220
46289
  *
46221
- * The expansion animates the content's grid track (0fr <-> 1fr rows for the
46222
- * stacked layout, columns for `layout="column"`) rather than `height`/`width`:
46223
- * the open size is "auto" (content-sized), which a length transition cannot
46224
- * interpolate to, while a fr track can. The content is clipped only while the
46225
- * track moves; once settled open it overflows normally again, so a popover or
46226
- * focus ring inside is not cut at the edges.
46290
+ * The animation is a REVEAL, not a resize: the expandable's own footprint
46291
+ * grows/shrinks progressively (the content's grid track interpolates
46292
+ * 0fr <-> 1fr rows for the stacked layout, columns for `layout="column"`),
46293
+ * but the content inside is laid out at its final size for the whole movement
46294
+ * (its animated dimension is frozen to the measured final value, see the
46295
+ * [opened] effect) and the container simply uncovers it. Text never rewraps
46296
+ * mid-animation. The content is revealed from its UI side (pinned against the
46297
+ * UI when it comes first). Once settled open the clipping is released, so a
46298
+ * popover or focus ring inside is not cut at the edges.
46227
46299
  */
46228
46300
  const css$J = /* css */`
46229
46301
  .navi_expandable {
@@ -46241,27 +46313,16 @@ const css$J = /* css */`
46241
46313
  cursor: pointer;
46242
46314
  user-select: none;
46243
46315
 
46244
- /* The whole row shows the focus, like a native <summary>, even though
46245
- only the marker button actually holds it. */
46246
- &:has(> .navi_expandable_toggle:focus-visible) {
46316
+ &:focus-visible {
46247
46317
  border-radius: 4px;
46248
46318
  outline: 2px solid AccentColor;
46249
46319
  outline-offset: 1px;
46250
46320
  }
46251
46321
 
46252
- > .navi_expandable_toggle {
46322
+ > .navi_expandable_marker {
46253
46323
  display: flex;
46254
- padding: 0;
46324
+ flex-shrink: 0;
46255
46325
  align-items: center;
46256
- color: inherit;
46257
- font: inherit;
46258
- background: none;
46259
- border: none;
46260
- cursor: pointer;
46261
-
46262
- &:focus-visible {
46263
- outline: none;
46264
- }
46265
46326
  }
46266
46327
 
46267
46328
  > .navi_expandable_ui_label {
@@ -46273,25 +46334,57 @@ const css$J = /* css */`
46273
46334
  }
46274
46335
 
46275
46336
  > .navi_expandable_content_container {
46337
+ position: relative;
46276
46338
  display: grid;
46277
46339
  grid-template-rows: 0fr;
46278
-
46279
- > .navi_expandable_content {
46340
+ /* The clip lives here, on the moving box, because the content inside
46341
+ keeps its final size during the animation (see the top comment) and
46342
+ overflows the track on purpose. One-sided (a clip-path with the free
46343
+ sides pushed far out) rather than overflow: hidden: only the side
46344
+ being revealed hides anything, so a badge sticking out of the other
46345
+ sides is visible from the very first frame of the movement. */
46346
+ clip-path: inset(-9999px -9999px 0 -9999px);
46347
+
46348
+ /* The sizer is what lets the track actually collapse: min-height 0 on
46349
+ an auto-sized item zeroes its min-content contribution. The frozen
46350
+ content cannot play that role itself — a definite height makes the
46351
+ contribution definite too, and the track then never goes below it. */
46352
+ > .navi_expandable_content_sizer {
46353
+ display: grid;
46280
46354
  min-height: 0;
46281
- overflow: hidden;
46282
46355
  }
46283
46356
  }
46284
46357
  &[aria-expanded="true"] > .navi_expandable_content_container {
46285
46358
  grid-template-rows: 1fr;
46286
46359
  }
46360
+ /* Content before the UI: revealed against the UI side — the edge touching
46361
+ the UI stays, the far edge is what gets uncovered. Said at BOTH levels:
46362
+ a transitioning fr resolves once for the container's own size and once
46363
+ more inside it (the row is fraction² high), so the row must be glued to
46364
+ the container's UI edge and the oversized frozen content to the row's —
46365
+ anchoring only the inner one leaves the content following the drifting
46366
+ fraction² row. */
46367
+ &[data-content-first]:not([data-layout="column"])
46368
+ > .navi_expandable_content_container {
46369
+ align-content: end;
46370
+ clip-path: inset(0 -9999px -9999px -9999px);
46287
46371
 
46288
- /* The parts sit side by side: the UI part becomes a vertical strip and
46289
- the content expands horizontally, on the columns track. */
46372
+ > .navi_expandable_content_sizer {
46373
+ align-content: end;
46374
+ }
46375
+ }
46376
+
46377
+ /* The parts sit side by side, sharing their height: the UI part becomes a
46378
+ vertical strip and the content expands horizontally, on the columns
46379
+ track — the rows track collapses too, so a closed expandable is only as
46380
+ tall as its UI (the content, unmounted or 0-wide, says nothing about
46381
+ the height it will bring). */
46290
46382
  &[data-layout="column"] {
46291
46383
  flex-direction: row;
46292
46384
 
46293
46385
  > .navi_expandable_ui {
46294
46386
  flex-direction: column;
46387
+ align-items: center;
46295
46388
 
46296
46389
  > .navi_expandable_ui_label {
46297
46390
  flex-direction: column;
@@ -46299,17 +46392,34 @@ const css$J = /* css */`
46299
46392
  }
46300
46393
  > .navi_expandable_content_container {
46301
46394
  grid-template-columns: 0fr;
46302
- grid-template-rows: none;
46395
+ grid-template-rows: 0fr;
46396
+ /* Both tracks reveal: clip the far side of each (right and bottom),
46397
+ the UI side and the top stay free. */
46398
+ clip-path: inset(-9999px 0 0 -9999px);
46303
46399
 
46304
- > .navi_expandable_content {
46400
+ > .navi_expandable_content_sizer {
46305
46401
  min-width: 0;
46306
- min-height: auto;
46402
+ min-height: 0;
46307
46403
  }
46308
46404
  }
46309
46405
  &[aria-expanded="true"] > .navi_expandable_content_container {
46310
46406
  grid-template-columns: 1fr;
46407
+ grid-template-rows: 1fr;
46408
+ }
46409
+ /* mountWhenClosed: the content is built and width-frozen while closed
46410
+ (see the component), so it can size the height at all times — the
46411
+ expandable then keeps one stable height and only the width reveals. */
46412
+ &[data-closed-content-sized] > .navi_expandable_content_container {
46311
46413
  grid-template-rows: none;
46312
46414
  }
46415
+ &[data-content-first] > .navi_expandable_content_container {
46416
+ justify-content: end;
46417
+ clip-path: inset(-9999px -9999px 0 0);
46418
+
46419
+ > .navi_expandable_content_sizer {
46420
+ justify-content: end;
46421
+ }
46422
+ }
46313
46423
  }
46314
46424
 
46315
46425
  &[data-animation] > .navi_expandable_content_container {
@@ -46323,16 +46433,21 @@ const css$J = /* css */`
46323
46433
  transition: none;
46324
46434
  }
46325
46435
  }
46326
- /* Settled open: stop clipping, so a popover, a focus ring or a dragged
46327
- element inside the content can spill out unless the content is given
46328
- a max height, where the clipping IS the feature (it scrolls). */
46329
- &[aria-expanded="true"][data-settled]:not([data-content-scrolls])
46330
- > .navi_expandable_content_container
46331
- > .navi_expandable_content {
46332
- overflow: visible;
46436
+ /* Settled open: stop clipping entirely, so a popover, a focus ring or a
46437
+ dragged element inside the content can spill out on any side. Settled
46438
+ closed: clip every side the collapsed box must show nothing, a
46439
+ stick-out included. In between (any movement, opening or closing) the
46440
+ one-sided clips above apply. */
46441
+ &[aria-expanded="true"][data-settled] > .navi_expandable_content_container {
46442
+ clip-path: none;
46443
+ }
46444
+ &:not([aria-expanded="true"])[data-settled]
46445
+ > .navi_expandable_content_container {
46446
+ clip-path: inset(0 0 0 0);
46333
46447
  }
46334
46448
  &[data-content-scrolls]
46335
46449
  > .navi_expandable_content_container
46450
+ > .navi_expandable_content_sizer
46336
46451
  > .navi_expandable_content {
46337
46452
  max-height: var(--navi-expandable-max-content-height);
46338
46453
  overflow-y: auto;
@@ -46387,21 +46502,22 @@ const useExpandableContext = partName => {
46387
46502
  * object (`{ loading, error, completed, ... }`) — see ActionRenderer.
46388
46503
  * @param loading - Shows the loading spinner on the marker regardless of
46389
46504
  * `action`'s own loading state.
46390
- * @param animation - Off by default. `true` plays the expand/collapse track
46391
- * transition; duration comes from `--navi-expandable-animation-duration`
46392
- * (0.3s).
46505
+ * @param animation - Off by default. `true` plays the reveal transition;
46506
+ * duration comes from `--navi-expandable-animation-duration` (0.3s).
46393
46507
  * @param layout - `"row"` (default): the parts stack, the content expands
46394
- * vertically. `"column"`: the parts sit side by side, the content expands
46395
- * horizontally next to the UI part.
46396
- * @param autoFocus - Off by default (the focus stays on the marker when
46508
+ * vertically. `"column"`: the parts sit side by side sharing their height,
46509
+ * the content expands horizontally next to the UI part.
46510
+ * @param autoFocus - Off by default (the focus stays on the UI part when
46397
46511
  * opening). `true` moves the focus into the content on open — the
46398
46512
  * `[autofocus]` element if any, the first focusable otherwise. Whatever the
46399
46513
  * setting, closing while the focus is inside the content hands it back to
46400
- * the marker (it would otherwise be lost to the closed, inert content).
46514
+ * the UI part (it would otherwise be lost to the closed, inert content).
46401
46515
  * @param maxContentHeight - Caps the content height; taller content scrolls
46402
46516
  * inside the expandable instead of growing it.
46403
46517
  * @param mountWhenClosed - Builds the content right away instead of on first
46404
- * expansion.
46518
+ * expansion. In layout="column" it also gives the closed expandable its
46519
+ * content's height (the content is kept laid out at its open width), so
46520
+ * opening only reveals the width instead of changing the height too.
46405
46521
  * @param unmountWhenClosed - Throws the content away once the collapse
46406
46522
  * settles — after the closing animation, so it still plays on real content —
46407
46523
  * and rebuilds it from scratch on every expansion.
@@ -46431,10 +46547,10 @@ const Expandable = props => {
46431
46547
  const defaultRef = useRef();
46432
46548
  const rootRef = ref || defaultRef;
46433
46549
  const uiRef = useRef();
46434
- const toggleButtonRef = useRef();
46435
46550
  const contentContainerRef = useRef();
46436
46551
  const contentId = useId();
46437
- const uiId = useId();
46552
+ const isColumn = layout === "column";
46553
+ const closedContentSized = Boolean(isColumn && mountWhenClosed);
46438
46554
  // Reading .value during render is what subscribes the expandable to it.
46439
46555
  const openRequested = signal ? signal.value : open;
46440
46556
  const [opened, setOpened] = useState(() => Boolean(openRequested === undefined ? defaultOpen : openRequested));
@@ -46455,16 +46571,44 @@ const Expandable = props => {
46455
46571
  const [settled, setSettled] = useState(true);
46456
46572
 
46457
46573
  // Read before the close touches the DOM: flipping the content to inert can
46458
- // blur what it held, so by effect time the focus to hand back to the marker
46574
+ // blur what it held, so by effect time the focus to hand back to the UI part
46459
46575
  // could already be gone.
46460
46576
  const focusedBeforeCloseRef = useRef(null);
46461
- // The pointer press that is about to toggle blurs the focused field before
46462
- // the click ever fires (pressing a non-focusable row moves the focus to
46463
- // body) — so what held the focus has to be remembered at pointerdown time.
46577
+ // The pointer press that is about to toggle can blur the focused field
46578
+ // before the click ever fires so what held the focus has to be remembered
46579
+ // at pointerdown time.
46464
46580
  const focusedAtPointerDownRef = useRef(null);
46465
46581
  const onUIPointerDown = () => {
46466
46582
  focusedAtPointerDownRef.current = document.activeElement;
46467
46583
  };
46584
+
46585
+ // The content keeps its final size while the track animates (see the top
46586
+ // comment): its animated dimension is pinned to a measured pixel value, and
46587
+ // released once the movement settles.
46588
+ const freezeContentSize = () => {
46589
+ const contentContainer = contentContainerRef.current;
46590
+ const contentElement = contentContainer ? contentContainer.firstElementChild.firstElementChild : null;
46591
+ if (!contentElement) {
46592
+ return;
46593
+ }
46594
+ const rect = contentElement.getBoundingClientRect();
46595
+ if (isColumn) {
46596
+ // Both, not just the width: a max-height-capped content otherwise
46597
+ // follows the collapsing rows track down instead of holding its size.
46598
+ contentElement.style.width = `${rect.width}px`;
46599
+ contentElement.style.height = `${rect.height}px`;
46600
+ } else {
46601
+ contentElement.style.height = `${rect.height}px`;
46602
+ }
46603
+ };
46604
+
46605
+ // Where the last paint left the track, measured before the toggle commits:
46606
+ // 0 when fully closed, partway when reopening during a collapse. Read here
46607
+ // rather than in the effect — a layout read after the commit would also be
46608
+ // the first style recalc of the open state, starting the track transition
46609
+ // right there; once canceled (to measure the final size), a new transition
46610
+ // to the same end value refuses to start and the reveal jumps.
46611
+ const revealStartSizeRef = useRef(null);
46468
46612
  const toggleTo = nextOpen => {
46469
46613
  nextOpen = Boolean(nextOpen);
46470
46614
  if (nextOpen === openedRef.current) {
@@ -46472,10 +46616,20 @@ const Expandable = props => {
46472
46616
  }
46473
46617
  openedRef.current = nextOpen;
46474
46618
  if (nextOpen) {
46619
+ if (animation) {
46620
+ const contentContainer = contentContainerRef.current;
46621
+ revealStartSizeRef.current = contentContainer ? contentContainer.getBoundingClientRect() : null;
46622
+ }
46475
46623
  setContentMounted(true);
46476
46624
  } else {
46477
46625
  const activeElement = document.activeElement;
46478
46626
  focusedBeforeCloseRef.current = !activeElement || activeElement === document.body ? focusedAtPointerDownRef.current : activeElement;
46627
+ if (animation) {
46628
+ // Now, while the content is still fully laid out — by effect time the
46629
+ // track is already heading to 0 (the opening case measures in the
46630
+ // effect instead, where the just-mounted content exists).
46631
+ freezeContentSize();
46632
+ }
46479
46633
  }
46480
46634
  focusedAtPointerDownRef.current = null;
46481
46635
  setOpened(nextOpen);
@@ -46524,10 +46678,9 @@ const Expandable = props => {
46524
46678
  }, [openRequested]);
46525
46679
 
46526
46680
  // A state change: tell the world (the "toggle" event), move the focus, and
46527
- // follow the transition to know when the movement is over. Skipped on mount —
46528
- // nothing changed, so neither the event nor a transition exists (and a page
46529
- // must not have its focus stolen by an expandable that was simply already
46530
- // open).
46681
+ // set up the reveal. Skipped on mount — nothing changed, so neither the
46682
+ // event nor a transition exists (and a page must not have its focus stolen
46683
+ // by an expandable that was simply already open).
46531
46684
  const isFirstOpenedRunRef = useRef(true);
46532
46685
  useLayoutEffect(() => {
46533
46686
  if (isFirstOpenedRunRef.current) {
@@ -46547,13 +46700,57 @@ const Expandable = props => {
46547
46700
  const focusedBeforeClose = focusedBeforeCloseRef.current;
46548
46701
  focusedBeforeCloseRef.current = null;
46549
46702
  if (focusedBeforeClose && contentContainerRef.current && contentContainerRef.current.contains(focusedBeforeClose)) {
46550
- toggleButtonRef.current.focus();
46703
+ uiRef.current.focus();
46551
46704
  }
46552
46705
  }
46553
46706
  if (!animation) {
46554
46707
  return undefined;
46555
46708
  }
46556
- const cancel = whenTransitionSettles(contentContainerRef.current, () => {
46709
+ const contentContainer = contentContainerRef.current;
46710
+ const contentElement = contentContainer.firstElementChild.firstElementChild;
46711
+ if (opened) {
46712
+ // The reveal needs the content at its final size before the track
46713
+ // starts moving, and the final size only exists in the open state —
46714
+ // the reflow trick (see instructions.md, CSS section), with transitions
46715
+ // suppressed BEFORE the first layout read: this effect runs pre-paint,
46716
+ // so any earlier read would itself be the first recalc of the open
46717
+ // state and would start the track transition (see revealStartSizeRef).
46718
+ contentContainer.style.transitionProperty = "none";
46719
+ const finalRect = contentElement.getBoundingClientRect();
46720
+ if (isColumn) {
46721
+ contentElement.style.width = `${finalRect.width}px`;
46722
+ contentElement.style.height = `${finalRect.height}px`;
46723
+ } else {
46724
+ contentElement.style.height = `${finalRect.height}px`;
46725
+ }
46726
+ // Put the tracks back where the last paint left them and let the
46727
+ // transition play from there. In fr — px does not interpolate with fr.
46728
+ const startRect = revealStartSizeRef.current;
46729
+ revealStartSizeRef.current = null;
46730
+ const startFrOf = (startSize, finalSize) => finalSize > 0 ? startSize / finalSize : 0;
46731
+ if (isColumn) {
46732
+ contentContainer.style.gridTemplateColumns = `${startFrOf(startRect ? startRect.width : 0, finalRect.width)}fr`;
46733
+ if (!closedContentSized) {
46734
+ // The height opens alongside the width (a closed column expandable
46735
+ // is only as tall as its UI) — unless the closed content already
46736
+ // sizes it, where only the width has anywhere to go.
46737
+ contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
46738
+ }
46739
+ } else {
46740
+ contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
46741
+ }
46742
+ // That starting frame must be genuinely rendered to transition from it,
46743
+ // and transitions re-enabled BEFORE the flip back to the open value —
46744
+ // same order as popover.jsx's own reflow trick.
46745
+ contentContainer.getBoundingClientRect();
46746
+ contentContainer.style.transitionProperty = "";
46747
+ contentContainer.style.gridTemplateColumns = "";
46748
+ contentContainer.style.gridTemplateRows = "";
46749
+ }
46750
+ // (closing froze the content in toggleTo, while it was still laid out)
46751
+ const cancel = whenTransitionSettles(contentContainer, () => {
46752
+ contentElement.style.width = "";
46753
+ contentElement.style.height = "";
46557
46754
  setSettled(true);
46558
46755
  });
46559
46756
  return cancel;
@@ -46569,6 +46766,30 @@ const Expandable = props => {
46569
46766
  }
46570
46767
  }, [mountWhenClosed]);
46571
46768
 
46769
+ // closedContentSized (column + mountWhenClosed): the closed content sizes
46770
+ // the height (see the CSS), which is only right if it lies at its OPEN
46771
+ // width — at its natural closed width (a 0-wide track) it would wrap
46772
+ // against nothing and stack word by word. So while closed, its width is
46773
+ // frozen to a silently measured open width.
46774
+ useLayoutEffect(() => {
46775
+ if (!closedContentSized || opened || !settled || !contentMounted) {
46776
+ return;
46777
+ }
46778
+ const contentContainer = contentContainerRef.current;
46779
+ const contentElement = contentContainer.firstElementChild.firstElementChild;
46780
+ contentContainer.style.transitionProperty = "none";
46781
+ contentContainer.style.gridTemplateColumns = "1fr";
46782
+ contentElement.style.width = "";
46783
+ const openRect = contentElement.getBoundingClientRect();
46784
+ contentElement.style.width = `${openRect.width}px`;
46785
+ contentContainer.style.gridTemplateColumns = "";
46786
+ // The closed frame must be committed while transitions are still off —
46787
+ // re-enabled in the same recalc, the 1fr-to-0fr trip back from the silent
46788
+ // measurement above would play as a second closing animation.
46789
+ contentContainer.getBoundingClientRect();
46790
+ contentContainer.style.transitionProperty = "";
46791
+ }, [closedContentSized, opened, settled, contentMounted]);
46792
+
46572
46793
  // Mounted already open: the content is visible, its data is due.
46573
46794
  useEffect(() => {
46574
46795
  if (openedRef.current && hasAction) {
@@ -46594,7 +46815,7 @@ const Expandable = props => {
46594
46815
  key
46595
46816
  } = keyboardEvent;
46596
46817
  if (key === openKeyShortcut) {
46597
- if (document.activeElement !== toggleButtonRef.current) {
46818
+ if (document.activeElement !== uiRef.current) {
46598
46819
  return;
46599
46820
  }
46600
46821
  if (!openedRef.current) {
@@ -46614,19 +46835,19 @@ const Expandable = props => {
46614
46835
  if (!openedRef.current) {
46615
46836
  return;
46616
46837
  }
46617
- const toggleButton = toggleButtonRef.current;
46618
- if (document.activeElement === toggleButton) {
46838
+ const uiElement = uiRef.current;
46839
+ if (document.activeElement === uiElement) {
46619
46840
  keyboardEvent.preventDefault();
46620
46841
  toggleTo(false);
46621
46842
  } else {
46622
46843
  keyboardEvent.preventDefault();
46623
- toggleButton.focus();
46844
+ uiElement.focus();
46624
46845
  }
46625
46846
  }
46626
46847
  };
46627
46848
  const onUIClick = clickEvent => {
46628
46849
  // A navi control inside the UI part cancels the click it consumed (see
46629
- // click_to_expand.js — the root's aria-expanded is what it finds).
46850
+ // click_to_expand.js — the UI part's own aria-expanded is what it finds).
46630
46851
  if (clickEvent.defaultPrevented) {
46631
46852
  return;
46632
46853
  }
@@ -46635,32 +46856,62 @@ const Expandable = props => {
46635
46856
  } = clickEvent;
46636
46857
  if (target.nodeType === 1) {
46637
46858
  const interactiveElement = target.closest(UI_INTERACTIVE_SELECTOR);
46638
- if (interactiveElement && interactiveElement !== toggleButtonRef.current && uiRef.current.contains(interactiveElement)) {
46859
+ if (interactiveElement && interactiveElement !== uiRef.current && uiRef.current.contains(interactiveElement)) {
46639
46860
  return;
46640
46861
  }
46641
46862
  }
46642
46863
  toggleTo(!openedRef.current);
46643
46864
  };
46865
+
46866
+ // Space/Enter on the UI part itself (role button) — a key pressed on a
46867
+ // control inside it belongs to that control.
46868
+ const onUIKeyDown = keyboardEvent => {
46869
+ if (keyboardEvent.defaultPrevented) {
46870
+ return;
46871
+ }
46872
+ if (keyboardEvent.target !== uiRef.current) {
46873
+ return;
46874
+ }
46875
+ const {
46876
+ key
46877
+ } = keyboardEvent;
46878
+ if (key === " " || key === "Enter") {
46879
+ keyboardEvent.preventDefault();
46880
+ toggleTo(!openedRef.current);
46881
+ }
46882
+ };
46883
+
46884
+ // Where the content went, so the marker can point at it while open (closed
46885
+ // always points right): below by default, above when the content part comes
46886
+ // first, beside for layout="column" (the chevron then points back toward
46887
+ // the UI: left).
46888
+ const childArray = toChildArray(children);
46889
+ const firstPart = childArray.find(child => child && (child.type === ExpandableUI || child.type === ExpandableContent));
46890
+ const hasParts = Boolean(firstPart);
46891
+ const contentFirst = hasParts && firstPart.type === ExpandableContent;
46892
+ const openDirection = isColumn ? "left" : contentFirst ? "up" : "down";
46644
46893
  const expandableContextValue = {
46645
46894
  opened,
46646
46895
  loading: loading || hasAction && actionLoading,
46647
46896
  contentMounted,
46648
46897
  hasAction,
46649
46898
  effectiveAction,
46899
+ openDirection,
46900
+ toggleTo,
46650
46901
  onUIClick,
46651
46902
  onUIPointerDown,
46903
+ onUIKeyDown,
46652
46904
  uiRef,
46653
- toggleButtonRef,
46654
46905
  contentContainerRef,
46655
- contentId,
46656
- uiId
46906
+ contentId
46657
46907
  };
46658
46908
 
46659
46909
  // Explicit parts win; the `ui` prop + children is the shorthand for the
46660
- // common shape (UI above, content below).
46661
- const childArray = toChildArray(children);
46662
- const hasParts = childArray.some(child => child && (child.type === ExpandableUI || child.type === ExpandableContent));
46663
- const body = hasParts ? children : jsxs(Fragment$1, {
46910
+ // common shape (UI above, content below). Parts are cloned on every render:
46911
+ // reference-stable children would be bailed out of the commit, leaving
46912
+ // their context subscription to re-render them asynchronously after the
46913
+ // [opened] effect above, which measures the content they render.
46914
+ const body = hasParts ? childArray.map(child => child && (child.type === ExpandableUI || child.type === ExpandableContent) ? cloneElement(child) : child) : jsxs(Fragment$1, {
46664
46915
  children: [jsx(ExpandableUI, {
46665
46916
  children: ui
46666
46917
  }), jsx(ExpandableContent, {
@@ -46671,10 +46922,12 @@ const Expandable = props => {
46671
46922
  ref: rootRef,
46672
46923
  baseClassName: "navi_expandable",
46673
46924
  "aria-expanded": opened ? "true" : "false",
46674
- "data-layout": layout === "column" ? "column" : undefined,
46925
+ "data-layout": isColumn ? "column" : undefined,
46926
+ "data-content-first": contentFirst ? "" : undefined,
46675
46927
  "data-animation": animation ? "" : undefined,
46676
46928
  "data-settled": settled ? "" : undefined,
46677
46929
  "data-content-scrolls": maxContentHeight === undefined ? undefined : "",
46930
+ "data-closed-content-sized": closedContentSized ? "" : undefined,
46678
46931
  ...rest,
46679
46932
  // The protocol every command target answers (see commands.js): a
46680
46933
  // `--navi-toggle`/`--navi-open`/`--navi-close` lands here as a
@@ -46707,10 +46960,12 @@ const Expandable = props => {
46707
46960
  };
46708
46961
 
46709
46962
  /**
46710
- * The always-visible part that reveals the content: the marker button plus
46711
- * whatever it is given any markup, a function of `{ open }` included. Its
46712
- * position among the parts decides where the content goes (before the content:
46713
- * content below/right; after it: content above/left).
46963
+ * The always-visible part that reveals the content: the focusable toggle
46964
+ * itself (role buttonclick, Space/Enter, arrow keys), holding the marker
46965
+ * plus whatever it is given any markup, a function of `{ open }` included.
46966
+ * Controls inside it keep their own behavior and do not toggle. Its position
46967
+ * among the parts decides where the content goes (before the content: content
46968
+ * below/right; after it: content above/left).
46714
46969
  *
46715
46970
  * @type {import("ignore:preact").FunctionComponent<{
46716
46971
  * children?: import("ignore:preact").ComponentChildren | ((state: { open: boolean }) => import("ignore:preact").ComponentChildren),
@@ -46723,32 +46978,50 @@ const ExpandableUI = ({
46723
46978
  const {
46724
46979
  opened,
46725
46980
  loading,
46981
+ openDirection,
46982
+ toggleTo,
46726
46983
  onUIClick,
46727
46984
  onUIPointerDown,
46985
+ onUIKeyDown,
46728
46986
  uiRef,
46729
- toggleButtonRef,
46730
- contentId,
46731
- uiId
46987
+ contentId
46732
46988
  } = useExpandableContext("UI");
46733
46989
  return jsxs("div", {
46734
46990
  ref: uiRef,
46735
46991
  className: "navi_expandable_ui",
46992
+ role: "button",
46993
+ tabIndex: 0,
46994
+ "aria-expanded": opened,
46995
+ "aria-controls": contentId,
46736
46996
  onClick: onUIClick,
46737
46997
  onPointerDown: onUIPointerDown,
46998
+ onKeyDown: onUIKeyDown
46999
+ // A command from a control inside the UI part resolves its target to
47000
+ // the closest [aria-expanded] — this very element (see commands.js's
47001
+ // resolveClosestExpandable) — so it answers the protocol too. Spread as
47002
+ // an object: eslint's known-DOM-property check doesn't apply to navi's
47003
+ // own custom events.
47004
+ ,
47005
+
47006
+ onnavi_command: e => {
47007
+ onNaviCommand(e);
47008
+ },
47009
+ onnavi_request_open: () => {
47010
+ toggleTo(true);
47011
+ },
47012
+ onnavi_request_close: () => {
47013
+ toggleTo(false);
47014
+ },
46738
47015
  ...rest,
46739
- children: [jsx("button", {
46740
- ref: toggleButtonRef,
46741
- type: "button",
46742
- className: "navi_expandable_toggle",
46743
- "aria-expanded": opened,
46744
- "aria-controls": contentId,
46745
- "aria-labelledby": uiId,
47016
+ children: [jsx("span", {
47017
+ className: "navi_expandable_marker",
47018
+ "aria-hidden": "true",
46746
47019
  children: jsx(SummaryMarker, {
46747
47020
  open: opened,
46748
- loading: loading
47021
+ loading: loading,
47022
+ openDirection: openDirection
46749
47023
  })
46750
47024
  }), jsx("div", {
46751
- id: uiId,
46752
47025
  className: "navi_expandable_ui_label",
46753
47026
  children: typeof children === "function" ? children({
46754
47027
  open: opened
@@ -46789,8 +47062,11 @@ const ExpandableContent = ({
46789
47062
  inert: opened ? undefined : true,
46790
47063
  ...rest,
46791
47064
  children: jsx("div", {
46792
- className: "navi_expandable_content",
46793
- children: contentMounted ? content : null
47065
+ className: "navi_expandable_content_sizer",
47066
+ children: jsx("div", {
47067
+ className: "navi_expandable_content",
47068
+ children: contentMounted ? content : null
47069
+ })
46794
47070
  })
46795
47071
  });
46796
47072
  };
@@ -46798,8 +47074,8 @@ Expandable.UI = ExpandableUI;
46798
47074
  Expandable.Content = ExpandableContent;
46799
47075
 
46800
47076
  // What a click inside the UI part must not toggle: it was aimed at the
46801
- // control, not at the row. The marker button is the one exception, excluded at
46802
- // the call site.
47077
+ // control, not at the row. The UI part itself matches [role='button'] and is
47078
+ // the one exception, excluded at the call site.
46803
47079
  const UI_INTERACTIVE_SELECTOR = ["a[href]", "button", "input", "select", "textarea", "label", "[role='button']", "[contenteditable='']", "[contenteditable='true']", "audio[controls]", "video[controls]"].join(", ");
46804
47080
  const createToggleEvent = open => {
46805
47081
  const newState = open ? "open" : "closed";
@@ -46884,7 +47160,8 @@ const ControlGroup = props => {
46884
47160
  stateType: "object",
46885
47161
  cascadeValidationToChildren: true,
46886
47162
  aggregateChildStates: props.aggregateChildStates,
46887
- distributeChildUIState: props.distributeChildUIState
47163
+ distributeChildUIState: props.distributeChildUIState,
47164
+ distributeChildStates: props.distributeChildStates
46888
47165
  });
46889
47166
  const {
46890
47167
  children
@@ -46898,6 +47175,7 @@ const ControlGroup = props => {
46898
47175
  ,
46899
47176
  aggregateChildStates: undefined,
46900
47177
  distributeChildUIState: undefined,
47178
+ distributeChildStates: undefined,
46901
47179
  pseudoClasses: CONTROL_GROUP_PSEUDO_CLASSES,
46902
47180
  children: jsx(ControlgroupChildrenWrapper, {
46903
47181
  ...childrenWrapperProps,
@@ -50335,7 +50613,7 @@ const seedDefaultValueFromSignal = (props) => {
50335
50613
  }
50336
50614
  };
50337
50615
 
50338
- const resolveInputProps = (props) => {
50616
+ const resolveInputProps = (props, { controlType = "input" } = {}) => {
50339
50617
  // `signal` carries a bound state signal. It is left on `props` on purpose:
50340
50618
  // `createControlInfo` (control_hooks.jsx) reads it to seed the state and to
50341
50619
  // follow it, and `onUIAction` (ui_state_controller.js) writes user
@@ -50352,8 +50630,20 @@ const resolveInputProps = (props) => {
50352
50630
  }
50353
50631
  }
50354
50632
  if (props.type === undefined && signalOptions.type !== undefined) {
50355
- props.type =
50633
+ const typeFromSignal =
50356
50634
  VALIDITY_TYPE_TO_INPUT_TYPE[signalOptions.type] ?? signalOptions.type;
50635
+ // What a signal says is what its value IS; what a control's `type` says
50636
+ // is what the control is. They usually agree — a date-typed signal wants
50637
+ // a date field — but a boolean one maps to a checkbox, and a picker made
50638
+ // into a checkbox is not a picker with a different look: it is another
50639
+ // control, with no popup to open. A picker asked to hold a yes/no keeps
50640
+ // its two rows and stays itself.
50641
+ const wouldChangeWhatTheControlIs =
50642
+ controlType === "picker" &&
50643
+ (typeFromSignal === "checkbox" || typeFromSignal === "radio");
50644
+ if (!wouldChangeWhatTheControlIs) {
50645
+ props.type = typeFromSignal;
50646
+ }
50357
50647
  }
50358
50648
  }
50359
50649
 
@@ -55179,6 +55469,20 @@ const PickerCustomResolver = props => {
55179
55469
  ...props
55180
55470
  });
55181
55471
  }
55472
+ if (props.type === undefined) {
55473
+ // A picker with a popup of its own holds whatever the control inside it
55474
+ // holds — a boolean, a number, an id — and a field with no type is read
55475
+ // back off the DOM, where every value is a string. "false" then matches no
55476
+ // row, the popup empties, and that emptiness climbs back into the picker:
55477
+ // a value survives its own round trip only while it is text. "navi_js" is
55478
+ // how a field says its value is a JS one, kept beside the DOM (see
55479
+ // controller_registry.js) — the same thing type="array"/"object" already
55480
+ // say for their shapes.
55481
+ return jsx(PickerCustom, {
55482
+ ...props,
55483
+ type: "navi_js"
55484
+ });
55485
+ }
55182
55486
  return jsx(PickerCustom, {
55183
55487
  ...props
55184
55488
  });
@@ -62190,7 +62494,7 @@ const PickerObjectUI = () => {
62190
62494
  value,
62191
62495
  placeholder
62192
62496
  } = useContext(PickerContext);
62193
- if (!value || Object.keys(value).length === 0) {
62497
+ if (uiStateHoldsNothing(value)) {
62194
62498
  if (!placeholder) {
62195
62499
  return null;
62196
62500
  }
@@ -62227,7 +62531,7 @@ const PickerArrayUI = () => {
62227
62531
  placeholder,
62228
62532
  maxLines
62229
62533
  } = useContext(PickerContext);
62230
- if (!value || value.length === 0) {
62534
+ if (uiStateHoldsNothing(value)) {
62231
62535
  if (!placeholder) {
62232
62536
  return null;
62233
62537
  }
@@ -63259,7 +63563,9 @@ const PickerFirstResolver = props => {
63259
63563
  const Next = useNextResolver();
63260
63564
  const defaultRef = useRef(null);
63261
63565
  props.ref = props.ref || defaultRef;
63262
- resolveInputProps(props);
63566
+ resolveInputProps(props, {
63567
+ controlType: "picker"
63568
+ });
63263
63569
  return jsx(Next, {
63264
63570
  ...props
63265
63571
  });
@@ -64335,7 +64641,8 @@ const SpinGroup = props => {
64335
64641
  stateType: "object",
64336
64642
  cascadeValidationToChildren: true,
64337
64643
  aggregateChildStates: props.aggregateChildStates,
64338
- distributeChildUIState: props.distributeChildUIState
64644
+ distributeChildUIState: props.distributeChildUIState,
64645
+ distributeChildStates: props.distributeChildStates
64339
64646
  });
64340
64647
  const {
64341
64648
  children
@@ -64352,6 +64659,7 @@ const SpinGroup = props => {
64352
64659
  // not reach the DOM as unknown attributes.
64353
64660
  aggregateChildStates: undefined,
64354
64661
  distributeChildUIState: undefined,
64662
+ distributeChildStates: undefined,
64355
64663
  baseClassName: "navi_spin_group",
64356
64664
  pseudoClasses: SPIN_GROUP_PSEUDO_CLASSES
64357
64665
  // What the frame and what sits between the spins are drawn from: the
@@ -68863,7 +69171,8 @@ const WheelGroup = props => {
68863
69171
  stateType: "object",
68864
69172
  cascadeValidationToChildren: true,
68865
69173
  aggregateChildStates: props.aggregateChildStates,
68866
- distributeChildUIState: props.distributeChildUIState
69174
+ distributeChildUIState: props.distributeChildUIState,
69175
+ distributeChildStates: props.distributeChildStates
68867
69176
  });
68868
69177
  const {
68869
69178
  children
@@ -68922,6 +69231,7 @@ const WheelGroup = props => {
68922
69231
  zoom: undefined,
68923
69232
  aggregateChildStates: undefined,
68924
69233
  distributeChildUIState: undefined,
69234
+ distributeChildStates: undefined,
68925
69235
  baseClassName: "navi_wheel_group",
68926
69236
  "data-horizontal": horizontal ? "" : undefined,
68927
69237
  style: groupStyle,