@jsenv/navi 0.29.91 → 0.29.93

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(
@@ -24200,12 +24208,25 @@ const useUIStateController = (
24200
24208
  } = controlInfo;
24201
24209
  controller.value = value;
24202
24210
  controller.defaultValue = defaultValue;
24211
+ // An optimistic control with work in flight keeps the user's intent on
24212
+ // screen: an incoming external state is the echo of an intermediate
24213
+ // commit (the first of two queued toggles landing), and the queued
24214
+ // request about to go out was built on top of the UI state —
24215
+ // overwriting it would both flash the superseded value and send it.
24216
+ // `controller.state` is still taken below: it is the last known good
24217
+ // state, the rollback target if the chain fails.
24218
+ const optimisticWorkInFlight = Boolean(
24219
+ props.optimistic &&
24220
+ (controller.actionInFlight || controller.queuedActionAllowedEvent),
24221
+ );
24203
24222
  if (hasStateProp) {
24204
24223
  controller.hasStateProp = true;
24205
24224
  const currentState = controller.state;
24206
24225
  if (!compareTwoJsValues(state, currentState)) {
24207
24226
  controller.state = state;
24208
- controller.setUIState(state, new CustomEvent("state_prop_change"));
24227
+ if (!optimisticWorkInFlight) {
24228
+ controller.setUIState(state, new CustomEvent("state_prop_change"));
24229
+ }
24209
24230
  }
24210
24231
  } else {
24211
24232
  if (controller.hasStateProp) {
@@ -24222,10 +24243,12 @@ const useUIStateController = (
24222
24243
  const currentState = controller.state;
24223
24244
  if (!compareTwoJsValues(stateFromSignal, currentState)) {
24224
24245
  controller.state = stateFromSignal;
24225
- controller.setUIState(
24226
- stateFromSignal,
24227
- new CustomEvent("state_prop_change"),
24228
- );
24246
+ if (!optimisticWorkInFlight) {
24247
+ controller.setUIState(
24248
+ stateFromSignal,
24249
+ new CustomEvent("state_prop_change"),
24250
+ );
24251
+ }
24229
24252
  }
24230
24253
  }
24231
24254
  }
@@ -24485,6 +24508,7 @@ const useUIGroupStateController = (
24485
24508
  childControlFilter,
24486
24509
  aggregateChildStates,
24487
24510
  distributeChildUIState,
24511
+ distributeChildStates,
24488
24512
  wantRequesterButtonState,
24489
24513
  uiActionInternal,
24490
24514
  allowCapture = false,
@@ -24501,7 +24525,7 @@ const useUIGroupStateController = (
24501
24525
  // whatever IT says — a "HH:MM", an ISO duration — and the shape checks in
24502
24526
  // setUIState below are about the default shape, not about that one.
24503
24527
  const stateShapeIsTheDefaultOne =
24504
- !aggregateChildStates && !distributeChildUIState;
24528
+ !aggregateChildStates && !distributeChildUIState && !distributeChildStates;
24505
24529
  const defaults = GROUP_DEFAULTS[controlType] ?? GROUP_DEFAULTS[stateType];
24506
24530
  const resolvedChildControlFilter =
24507
24531
  childControlFilter ?? defaults?.childControlFilter ?? null;
@@ -24509,6 +24533,12 @@ const useUIGroupStateController = (
24509
24533
  aggregateChildStates ?? defaults?.aggregateChildStates;
24510
24534
  const resolvedDistributeChildUIState =
24511
24535
  distributeChildUIState ?? defaults?.distributeChildUIState;
24536
+ // The plural half of the pair: `aggregateChildStates` already sees all the
24537
+ // children at once, and a group whose value is not one key per child usually
24538
+ // needs the same view on the way down — which of four seats each player takes
24539
+ // cannot be decided one seat at a time. Given both, the plural one wins.
24540
+ const resolvedDistributeChildStates =
24541
+ distributeChildStates ?? defaults?.distributeChildStates;
24512
24542
  if (
24513
24543
  typeof resolvedAggregateChildStates !== "function" ||
24514
24544
  typeof resolvedDistributeChildUIState !== "function"
@@ -24763,6 +24793,48 @@ const useUIGroupStateController = (
24763
24793
  // Where the group puts a value on ONE child: the only place that knows
24764
24794
  // what each child gets, and the only one that sees a child it cannot
24765
24795
  // place — see warnChildAnswersForItself.
24796
+ // One pass over every child, which is what a plural distribute needs:
24797
+ // it is asked once, sees the whole group, and answers for all of them.
24798
+ placeChildrenUIState: (groupUIState, e) => {
24799
+ if (!resolvedDistributeChildStates) {
24800
+ for (const childUIStateController of childUIStateControllerArray) {
24801
+ controller.placeChildUIState(
24802
+ childUIStateController,
24803
+ groupUIState,
24804
+ e,
24805
+ );
24806
+ }
24807
+ return;
24808
+ }
24809
+ const monitoredChildren = childUIStateControllerArray.filter(
24810
+ shouldPropagateStateToChild,
24811
+ );
24812
+ const stateByChild = resolvedDistributeChildStates(
24813
+ groupUIState,
24814
+ monitoredChildren,
24815
+ );
24816
+ if (!stateByChild) {
24817
+ return;
24818
+ }
24819
+ for (const childUIStateController of monitoredChildren) {
24820
+ if (!stateByChild.has(childUIStateController)) {
24821
+ // Not named by the answer: left where it is, the way
24822
+ // CANNOT_DERIVE leaves a child a per-child distribute says
24823
+ // nothing about.
24824
+ continue;
24825
+ }
24826
+ if (
24827
+ childUIStateController.hasStateProp &&
24828
+ !childUIStateController.props.signal
24829
+ ) {
24830
+ continue;
24831
+ }
24832
+ childUIStateController.setUIState(
24833
+ stateByChild.get(childUIStateController),
24834
+ e,
24835
+ );
24836
+ }
24837
+ },
24766
24838
  placeChildUIState: (childUIStateController, groupUIState, e) => {
24767
24839
  if (!shouldPropagateStateToChild(childUIStateController)) {
24768
24840
  return;
@@ -24824,13 +24896,7 @@ const useUIGroupStateController = (
24824
24896
  detail: {},
24825
24897
  });
24826
24898
  chainEvent(propagateDownEvent, e);
24827
- for (const childUIStateController of childUIStateControllerArray) {
24828
- controller.placeChildUIState(
24829
- childUIStateController,
24830
- newUIState,
24831
- propagateDownEvent,
24832
- );
24833
- }
24899
+ controller.placeChildrenUIState(newUIState, propagateDownEvent);
24834
24900
  const groupUIState = aggregateGroupUIState(newUIState);
24835
24901
  if (e.type === "initial_state_push") {
24836
24902
  controller.syncInternalState(groupUIState);
@@ -24905,11 +24971,21 @@ const useUIGroupStateController = (
24905
24971
  const initialEvent = new CustomEvent("initial_state_push", {
24906
24972
  detail: {},
24907
24973
  });
24908
- controller.placeChildUIState(
24909
- childUIStateController,
24910
- stateToPlaceChildFrom,
24911
- initialEvent,
24912
- );
24974
+ if (resolvedDistributeChildStates) {
24975
+ // A group answering for all its children at once has to be asked
24976
+ // again now that there is one more: what each of them shows may
24977
+ // depend on who else is there.
24978
+ controller.placeChildrenUIState(
24979
+ stateToPlaceChildFrom,
24980
+ initialEvent,
24981
+ );
24982
+ } else {
24983
+ controller.placeChildUIState(
24984
+ childUIStateController,
24985
+ stateToPlaceChildFrom,
24986
+ initialEvent,
24987
+ );
24988
+ }
24913
24989
  }
24914
24990
  onChange(new CustomEvent(`${childControlType}_mount`), {
24915
24991
  notifyExternal: "silent",
@@ -25084,13 +25160,7 @@ const useUIGroupStateController = (
25084
25160
  "propagate_down_set_ui_state",
25085
25161
  { detail: {} },
25086
25162
  );
25087
- for (const childUIStateController of childUIStateControllerArray) {
25088
- controller.placeChildUIState(
25089
- childUIStateController,
25090
- groupUIState,
25091
- propagateDownEvent,
25092
- );
25093
- }
25163
+ controller.placeChildrenUIState(groupUIState, propagateDownEvent);
25094
25164
  controller.syncInternalState(groupUIState);
25095
25165
  };
25096
25166
  if (
@@ -25381,6 +25451,7 @@ const useUIFacadeStateController = (props, realUIStateController) => {
25381
25451
  return {
25382
25452
  controller: facadeUIStateController,
25383
25453
  realUIStateController,
25454
+ props,
25384
25455
  };
25385
25456
  },
25386
25457
  // ── update: runs every render after the first ─────────────────────────
@@ -25394,6 +25465,7 @@ const useUIFacadeStateController = (props, realUIStateController) => {
25394
25465
 
25395
25466
  return {
25396
25467
  realUIStateController,
25468
+ props,
25397
25469
  };
25398
25470
  },
25399
25471
  );
@@ -26444,7 +26516,11 @@ const createControlInfo = (props, {
26444
26516
  if (typeProp === "checkbox" || typeProp === "radio") {
26445
26517
  statePropName = "checked";
26446
26518
  defaultStatePropName = "defaultChecked";
26447
- value = props.value || "on";
26519
+ // "on" is what HTML sends for a checkbox given no value of its own — the
26520
+ // default of an ABSENT prop, not of a falsy one: `value={false}` and
26521
+ // `value={0}` are values, and two rows holding true and false are how a
26522
+ // list asks a yes/no question.
26523
+ value = props.value === undefined ? "on" : props.value;
26448
26524
  signalHoldsChecked = true;
26449
26525
  if (signal) {
26450
26526
  if (props.defaultChecked) {
@@ -26629,6 +26705,7 @@ const useControlgroupProps = (props, {
26629
26705
  childControlFilter,
26630
26706
  aggregateChildStates,
26631
26707
  distributeChildUIState,
26708
+ distributeChildStates,
26632
26709
  wantRequesterButtonState,
26633
26710
  uiActionInternal,
26634
26711
  allowCapture = false,
@@ -26642,6 +26719,7 @@ const useControlgroupProps = (props, {
26642
26719
  childControlFilter,
26643
26720
  aggregateChildStates,
26644
26721
  distributeChildUIState,
26722
+ distributeChildStates,
26645
26723
  wantRequesterButtonState,
26646
26724
  uiActionInternal,
26647
26725
  allowCapture,
@@ -27156,7 +27234,6 @@ const useInteractiveProps = (props, {
27156
27234
  uiStateController.actionInFlight = false;
27157
27235
  uiStateController.runningAction = null;
27158
27236
  const queuedEvent = uiStateController.queuedActionAllowedEvent;
27159
- uiStateController.queuedActionAllowedEvent = null;
27160
27237
  if (!queuedEvent) {
27161
27238
  return;
27162
27239
  }
@@ -27164,12 +27241,20 @@ const useInteractiveProps = (props, {
27164
27241
  // A failure abandons the queue: the UI is rolled back to the last
27165
27242
  // known state (resetOnError above), and what was queued was built
27166
27243
  // on top of the state that just failed.
27244
+ uiStateController.queuedActionAllowedEvent = null;
27167
27245
  return;
27168
27246
  }
27169
27247
  // A microtask later, not right here: this runs inside the batch()
27170
27248
  // that settles the action (see watchActionCompletion for the same
27171
27249
  // constraint).
27172
27250
  queueMicrotask(() => {
27251
+ // Cleared here, right before the dispatch, never earlier: between
27252
+ // the outcome and this microtask a render can slip in (the echo
27253
+ // of what the settled run committed), and the external-state gate
27254
+ // in ui_state_controller.js must still see work in flight or it
27255
+ // would overwrite the UI state the queued request is about to
27256
+ // send.
27257
+ uiStateController.queuedActionAllowedEvent = null;
27173
27258
  executeAction(queuedEvent);
27174
27259
  });
27175
27260
  });
@@ -45833,7 +45918,6 @@ const useFocusGroup = (
45833
45918
  };
45834
45919
 
45835
45920
  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
45921
  const css$L = /* css */`
45838
45922
  .navi_summary_marker {
45839
45923
  width: 1em;
@@ -45858,22 +45942,31 @@ const css$L = /* css */`
45858
45942
  }
45859
45943
  }
45860
45944
 
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;
45945
+ /* One chevron, rotated: the transition only ever plays on a direction
45946
+ change, so the first paint shows the resting direction with no
45947
+ movement. */
45948
+ .navi_summary_marker_arrow_group {
45949
+ transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
45867
45950
 
45868
- &[data-animation-target="down"] {
45869
- animation-name: morph-to-down;
45951
+ &[data-direction="right"] {
45952
+ transform: rotate(0deg);
45870
45953
  }
45871
-
45872
- &[data-animation-target="right"] {
45873
- animation-name: morph-to-right;
45954
+ &[data-direction="down"] {
45955
+ transform: rotate(90deg);
45956
+ }
45957
+ &[data-direction="up"] {
45958
+ transform: rotate(-90deg);
45959
+ }
45960
+ &[data-direction="left"] {
45961
+ transform: rotate(180deg);
45874
45962
  }
45875
45963
  }
45876
45964
 
45965
+ .navi_summary_marker_arrow {
45966
+ opacity: 1;
45967
+ transition: opacity 0.3s ease-in-out;
45968
+ }
45969
+
45877
45970
  &[data-loading] {
45878
45971
  .navi_summary_marker_loading_container {
45879
45972
  transform: scale(1);
@@ -45898,39 +45991,25 @@ const css$L = /* css */`
45898
45991
  stroke-dashoffset: -2010;
45899
45992
  }
45900
45993
  }
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
45994
  `;
45995
+
45996
+ /**
45997
+ * @type {import("ignore:preact").FunctionComponent<{
45998
+ * open?: boolean,
45999
+ * loading?: boolean,
46000
+ * openDirection?: "down" | "up" | "left",
46001
+ * }>}
46002
+ * @param openDirection - Where the chevron points while open; closed always
46003
+ * points right. "down" fits content revealed below (the <details> shape),
46004
+ * "up" content revealed above, "left" content revealed beside.
46005
+ */
45918
46006
  const SummaryMarker = ({
45919
46007
  open,
45920
- loading
46008
+ loading,
46009
+ openDirection = "down"
45921
46010
  }) => {
45922
46011
  import.meta.css = [css$L, "@jsenv/navi/src/control/details/summary_marker.jsx"];
45923
46012
  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
46013
  return jsx("span", {
45935
46014
  className: "navi_summary_marker",
45936
46015
  "data-loading": showLoading ? "" : undefined,
@@ -45961,12 +46040,13 @@ const SummaryMarker = ({
45961
46040
  strokeDasharray: "503 1507"
45962
46041
  })]
45963
46042
  }), jsx("g", {
46043
+ className: "navi_summary_marker_arrow_group",
46044
+ "data-direction": open ? openDirection : "right",
45964
46045
  "transform-origin": "480px -480px",
45965
46046
  children: jsx("path", {
45966
46047
  className: "navi_summary_marker_arrow",
45967
46048
  fill: "currentColor",
45968
- "data-animation-target": shouldAnimate ? open ? "down" : "right" : undefined,
45969
- d: open ? downArrowPath : rightArrowPath
46049
+ d: rightArrowPath
45970
46050
  })
45971
46051
  })]
45972
46052
  })
@@ -46196,21 +46276,32 @@ installImportMetaCssBuild(import.meta);/**
46196
46276
  * </Expandable>
46197
46277
  *
46198
46278
  * 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.
46279
+ * expands above; `layout="column"` puts the parts side by side (sharing
46280
+ * their height), the content then expanding horizontally. The marker
46281
+ * chevron follows: it points right while closed and toward where the
46282
+ * content went while open (down, up, or left). The common shape has a
46283
+ * shorthand: `ui` prop + children as content.
46284
+ * - the UI part is the focusable toggle itself (role button, Space/Enter,
46285
+ * arrow keys) and accepts any markup: controls inside it keep their own
46286
+ * behavior, the marker is purely decorative.
46287
+ *
46288
+ * Reach for it knowingly: expanding in-flow SHIFTS the layout — everything
46289
+ * below (or beside) moves when it opens. A Popover, Dialog, Picker or Callout
46290
+ * answers the same click on its own layer, moving nothing, which is usually
46291
+ * the better UX: a layout that stays where it is reads and operates better —
46292
+ * all the more on small screens, mobile first of all, where the shift can
46293
+ * push most of the page away. Expandable is for content that genuinely
46294
+ * belongs in the flow (a tree, a changelog, a settings group read top to
46295
+ * bottom).
46206
46296
  *
46207
46297
  * What <details> gives for free is rebuilt here:
46208
46298
  * - a "toggle" event (a real ToggleEvent when the browser has it) dispatched on
46209
46299
  * the root whenever the state actually changes — but never on mount, unlike
46210
46300
  * the native one (see the workaround comment in details.jsx);
46211
46301
  * - `--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.
46302
+ * root and the UI part carry `aria-expanded` (what the command system reads)
46303
+ * and answer the `navi_command`/`navi_request_open`/`navi_request_close`
46304
+ * events.
46214
46305
  *
46215
46306
  * Content is not built until the first expansion and stays built afterwards —
46216
46307
  * same policy, same prop names as popups (see popup_content_mount.js):
@@ -46218,12 +46309,15 @@ installImportMetaCssBuild(import.meta);/**
46218
46309
  * once the collapse settles (so a closing animation still plays on real
46219
46310
  * content).
46220
46311
  *
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.
46312
+ * The animation is a REVEAL, not a resize: the expandable's own footprint
46313
+ * grows/shrinks progressively (the content's grid track interpolates
46314
+ * 0fr <-> 1fr rows for the stacked layout, columns for `layout="column"`),
46315
+ * but the content inside is laid out at its final size for the whole movement
46316
+ * (its animated dimension is frozen to the measured final value, see the
46317
+ * [opened] effect) and the container simply uncovers it. Text never rewraps
46318
+ * mid-animation. The content is revealed from its UI side (pinned against the
46319
+ * UI when it comes first). Once settled open the clipping is released, so a
46320
+ * popover or focus ring inside is not cut at the edges.
46227
46321
  */
46228
46322
  const css$J = /* css */`
46229
46323
  .navi_expandable {
@@ -46241,27 +46335,16 @@ const css$J = /* css */`
46241
46335
  cursor: pointer;
46242
46336
  user-select: none;
46243
46337
 
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) {
46338
+ &:focus-visible {
46247
46339
  border-radius: 4px;
46248
46340
  outline: 2px solid AccentColor;
46249
46341
  outline-offset: 1px;
46250
46342
  }
46251
46343
 
46252
- > .navi_expandable_toggle {
46344
+ > .navi_expandable_marker {
46253
46345
  display: flex;
46254
- padding: 0;
46346
+ flex-shrink: 0;
46255
46347
  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
46348
  }
46266
46349
 
46267
46350
  > .navi_expandable_ui_label {
@@ -46273,25 +46356,57 @@ const css$J = /* css */`
46273
46356
  }
46274
46357
 
46275
46358
  > .navi_expandable_content_container {
46359
+ position: relative;
46276
46360
  display: grid;
46277
46361
  grid-template-rows: 0fr;
46278
-
46279
- > .navi_expandable_content {
46362
+ /* The clip lives here, on the moving box, because the content inside
46363
+ keeps its final size during the animation (see the top comment) and
46364
+ overflows the track on purpose. One-sided (a clip-path with the free
46365
+ sides pushed far out) rather than overflow: hidden: only the side
46366
+ being revealed hides anything, so a badge sticking out of the other
46367
+ sides is visible from the very first frame of the movement. */
46368
+ clip-path: inset(-9999px -9999px 0 -9999px);
46369
+
46370
+ /* The sizer is what lets the track actually collapse: min-height 0 on
46371
+ an auto-sized item zeroes its min-content contribution. The frozen
46372
+ content cannot play that role itself — a definite height makes the
46373
+ contribution definite too, and the track then never goes below it. */
46374
+ > .navi_expandable_content_sizer {
46375
+ display: grid;
46280
46376
  min-height: 0;
46281
- overflow: hidden;
46282
46377
  }
46283
46378
  }
46284
46379
  &[aria-expanded="true"] > .navi_expandable_content_container {
46285
46380
  grid-template-rows: 1fr;
46286
46381
  }
46382
+ /* Content before the UI: revealed against the UI side — the edge touching
46383
+ the UI stays, the far edge is what gets uncovered. Said at BOTH levels:
46384
+ a transitioning fr resolves once for the container's own size and once
46385
+ more inside it (the row is fraction² high), so the row must be glued to
46386
+ the container's UI edge and the oversized frozen content to the row's —
46387
+ anchoring only the inner one leaves the content following the drifting
46388
+ fraction² row. */
46389
+ &[data-content-first]:not([data-layout="column"])
46390
+ > .navi_expandable_content_container {
46391
+ align-content: end;
46392
+ clip-path: inset(0 -9999px -9999px -9999px);
46287
46393
 
46288
- /* The parts sit side by side: the UI part becomes a vertical strip and
46289
- the content expands horizontally, on the columns track. */
46394
+ > .navi_expandable_content_sizer {
46395
+ align-content: end;
46396
+ }
46397
+ }
46398
+
46399
+ /* The parts sit side by side, sharing their height: the UI part becomes a
46400
+ vertical strip and the content expands horizontally, on the columns
46401
+ track — the rows track collapses too, so a closed expandable is only as
46402
+ tall as its UI (the content, unmounted or 0-wide, says nothing about
46403
+ the height it will bring). */
46290
46404
  &[data-layout="column"] {
46291
46405
  flex-direction: row;
46292
46406
 
46293
46407
  > .navi_expandable_ui {
46294
46408
  flex-direction: column;
46409
+ align-items: center;
46295
46410
 
46296
46411
  > .navi_expandable_ui_label {
46297
46412
  flex-direction: column;
@@ -46299,17 +46414,34 @@ const css$J = /* css */`
46299
46414
  }
46300
46415
  > .navi_expandable_content_container {
46301
46416
  grid-template-columns: 0fr;
46302
- grid-template-rows: none;
46417
+ grid-template-rows: 0fr;
46418
+ /* Both tracks reveal: clip the far side of each (right and bottom),
46419
+ the UI side and the top stay free. */
46420
+ clip-path: inset(-9999px 0 0 -9999px);
46303
46421
 
46304
- > .navi_expandable_content {
46422
+ > .navi_expandable_content_sizer {
46305
46423
  min-width: 0;
46306
- min-height: auto;
46424
+ min-height: 0;
46307
46425
  }
46308
46426
  }
46309
46427
  &[aria-expanded="true"] > .navi_expandable_content_container {
46310
46428
  grid-template-columns: 1fr;
46429
+ grid-template-rows: 1fr;
46430
+ }
46431
+ /* mountWhenClosed: the content is built and width-frozen while closed
46432
+ (see the component), so it can size the height at all times — the
46433
+ expandable then keeps one stable height and only the width reveals. */
46434
+ &[data-closed-content-sized] > .navi_expandable_content_container {
46311
46435
  grid-template-rows: none;
46312
46436
  }
46437
+ &[data-content-first] > .navi_expandable_content_container {
46438
+ justify-content: end;
46439
+ clip-path: inset(-9999px -9999px 0 0);
46440
+
46441
+ > .navi_expandable_content_sizer {
46442
+ justify-content: end;
46443
+ }
46444
+ }
46313
46445
  }
46314
46446
 
46315
46447
  &[data-animation] > .navi_expandable_content_container {
@@ -46323,16 +46455,21 @@ const css$J = /* css */`
46323
46455
  transition: none;
46324
46456
  }
46325
46457
  }
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;
46458
+ /* Settled open: stop clipping entirely, so a popover, a focus ring or a
46459
+ dragged element inside the content can spill out on any side. Settled
46460
+ closed: clip every side the collapsed box must show nothing, a
46461
+ stick-out included. In between (any movement, opening or closing) the
46462
+ one-sided clips above apply. */
46463
+ &[aria-expanded="true"][data-settled] > .navi_expandable_content_container {
46464
+ clip-path: none;
46465
+ }
46466
+ &:not([aria-expanded="true"])[data-settled]
46467
+ > .navi_expandable_content_container {
46468
+ clip-path: inset(0 0 0 0);
46333
46469
  }
46334
46470
  &[data-content-scrolls]
46335
46471
  > .navi_expandable_content_container
46472
+ > .navi_expandable_content_sizer
46336
46473
  > .navi_expandable_content {
46337
46474
  max-height: var(--navi-expandable-max-content-height);
46338
46475
  overflow-y: auto;
@@ -46387,21 +46524,22 @@ const useExpandableContext = partName => {
46387
46524
  * object (`{ loading, error, completed, ... }`) — see ActionRenderer.
46388
46525
  * @param loading - Shows the loading spinner on the marker regardless of
46389
46526
  * `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).
46527
+ * @param animation - Off by default. `true` plays the reveal transition;
46528
+ * duration comes from `--navi-expandable-animation-duration` (0.3s).
46393
46529
  * @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
46530
+ * vertically. `"column"`: the parts sit side by side sharing their height,
46531
+ * the content expands horizontally next to the UI part.
46532
+ * @param autoFocus - Off by default (the focus stays on the UI part when
46397
46533
  * opening). `true` moves the focus into the content on open — the
46398
46534
  * `[autofocus]` element if any, the first focusable otherwise. Whatever the
46399
46535
  * 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).
46536
+ * the UI part (it would otherwise be lost to the closed, inert content).
46401
46537
  * @param maxContentHeight - Caps the content height; taller content scrolls
46402
46538
  * inside the expandable instead of growing it.
46403
46539
  * @param mountWhenClosed - Builds the content right away instead of on first
46404
- * expansion.
46540
+ * expansion. In layout="column" it also gives the closed expandable its
46541
+ * content's height (the content is kept laid out at its open width), so
46542
+ * opening only reveals the width instead of changing the height too.
46405
46543
  * @param unmountWhenClosed - Throws the content away once the collapse
46406
46544
  * settles — after the closing animation, so it still plays on real content —
46407
46545
  * and rebuilds it from scratch on every expansion.
@@ -46431,10 +46569,10 @@ const Expandable = props => {
46431
46569
  const defaultRef = useRef();
46432
46570
  const rootRef = ref || defaultRef;
46433
46571
  const uiRef = useRef();
46434
- const toggleButtonRef = useRef();
46435
46572
  const contentContainerRef = useRef();
46436
46573
  const contentId = useId();
46437
- const uiId = useId();
46574
+ const isColumn = layout === "column";
46575
+ const closedContentSized = Boolean(isColumn && mountWhenClosed);
46438
46576
  // Reading .value during render is what subscribes the expandable to it.
46439
46577
  const openRequested = signal ? signal.value : open;
46440
46578
  const [opened, setOpened] = useState(() => Boolean(openRequested === undefined ? defaultOpen : openRequested));
@@ -46455,16 +46593,44 @@ const Expandable = props => {
46455
46593
  const [settled, setSettled] = useState(true);
46456
46594
 
46457
46595
  // 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
46596
+ // blur what it held, so by effect time the focus to hand back to the UI part
46459
46597
  // could already be gone.
46460
46598
  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.
46599
+ // The pointer press that is about to toggle can blur the focused field
46600
+ // before the click ever fires so what held the focus has to be remembered
46601
+ // at pointerdown time.
46464
46602
  const focusedAtPointerDownRef = useRef(null);
46465
46603
  const onUIPointerDown = () => {
46466
46604
  focusedAtPointerDownRef.current = document.activeElement;
46467
46605
  };
46606
+
46607
+ // The content keeps its final size while the track animates (see the top
46608
+ // comment): its animated dimension is pinned to a measured pixel value, and
46609
+ // released once the movement settles.
46610
+ const freezeContentSize = () => {
46611
+ const contentContainer = contentContainerRef.current;
46612
+ const contentElement = contentContainer ? contentContainer.firstElementChild.firstElementChild : null;
46613
+ if (!contentElement) {
46614
+ return;
46615
+ }
46616
+ const rect = contentElement.getBoundingClientRect();
46617
+ if (isColumn) {
46618
+ // Both, not just the width: a max-height-capped content otherwise
46619
+ // follows the collapsing rows track down instead of holding its size.
46620
+ contentElement.style.width = `${rect.width}px`;
46621
+ contentElement.style.height = `${rect.height}px`;
46622
+ } else {
46623
+ contentElement.style.height = `${rect.height}px`;
46624
+ }
46625
+ };
46626
+
46627
+ // Where the last paint left the track, measured before the toggle commits:
46628
+ // 0 when fully closed, partway when reopening during a collapse. Read here
46629
+ // rather than in the effect — a layout read after the commit would also be
46630
+ // the first style recalc of the open state, starting the track transition
46631
+ // right there; once canceled (to measure the final size), a new transition
46632
+ // to the same end value refuses to start and the reveal jumps.
46633
+ const revealStartSizeRef = useRef(null);
46468
46634
  const toggleTo = nextOpen => {
46469
46635
  nextOpen = Boolean(nextOpen);
46470
46636
  if (nextOpen === openedRef.current) {
@@ -46472,10 +46638,20 @@ const Expandable = props => {
46472
46638
  }
46473
46639
  openedRef.current = nextOpen;
46474
46640
  if (nextOpen) {
46641
+ if (animation) {
46642
+ const contentContainer = contentContainerRef.current;
46643
+ revealStartSizeRef.current = contentContainer ? contentContainer.getBoundingClientRect() : null;
46644
+ }
46475
46645
  setContentMounted(true);
46476
46646
  } else {
46477
46647
  const activeElement = document.activeElement;
46478
46648
  focusedBeforeCloseRef.current = !activeElement || activeElement === document.body ? focusedAtPointerDownRef.current : activeElement;
46649
+ if (animation) {
46650
+ // Now, while the content is still fully laid out — by effect time the
46651
+ // track is already heading to 0 (the opening case measures in the
46652
+ // effect instead, where the just-mounted content exists).
46653
+ freezeContentSize();
46654
+ }
46479
46655
  }
46480
46656
  focusedAtPointerDownRef.current = null;
46481
46657
  setOpened(nextOpen);
@@ -46524,10 +46700,9 @@ const Expandable = props => {
46524
46700
  }, [openRequested]);
46525
46701
 
46526
46702
  // 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).
46703
+ // set up the reveal. Skipped on mount — nothing changed, so neither the
46704
+ // event nor a transition exists (and a page must not have its focus stolen
46705
+ // by an expandable that was simply already open).
46531
46706
  const isFirstOpenedRunRef = useRef(true);
46532
46707
  useLayoutEffect(() => {
46533
46708
  if (isFirstOpenedRunRef.current) {
@@ -46547,13 +46722,57 @@ const Expandable = props => {
46547
46722
  const focusedBeforeClose = focusedBeforeCloseRef.current;
46548
46723
  focusedBeforeCloseRef.current = null;
46549
46724
  if (focusedBeforeClose && contentContainerRef.current && contentContainerRef.current.contains(focusedBeforeClose)) {
46550
- toggleButtonRef.current.focus();
46725
+ uiRef.current.focus();
46551
46726
  }
46552
46727
  }
46553
46728
  if (!animation) {
46554
46729
  return undefined;
46555
46730
  }
46556
- const cancel = whenTransitionSettles(contentContainerRef.current, () => {
46731
+ const contentContainer = contentContainerRef.current;
46732
+ const contentElement = contentContainer.firstElementChild.firstElementChild;
46733
+ if (opened) {
46734
+ // The reveal needs the content at its final size before the track
46735
+ // starts moving, and the final size only exists in the open state —
46736
+ // the reflow trick (see instructions.md, CSS section), with transitions
46737
+ // suppressed BEFORE the first layout read: this effect runs pre-paint,
46738
+ // so any earlier read would itself be the first recalc of the open
46739
+ // state and would start the track transition (see revealStartSizeRef).
46740
+ contentContainer.style.transitionProperty = "none";
46741
+ const finalRect = contentElement.getBoundingClientRect();
46742
+ if (isColumn) {
46743
+ contentElement.style.width = `${finalRect.width}px`;
46744
+ contentElement.style.height = `${finalRect.height}px`;
46745
+ } else {
46746
+ contentElement.style.height = `${finalRect.height}px`;
46747
+ }
46748
+ // Put the tracks back where the last paint left them and let the
46749
+ // transition play from there. In fr — px does not interpolate with fr.
46750
+ const startRect = revealStartSizeRef.current;
46751
+ revealStartSizeRef.current = null;
46752
+ const startFrOf = (startSize, finalSize) => finalSize > 0 ? startSize / finalSize : 0;
46753
+ if (isColumn) {
46754
+ contentContainer.style.gridTemplateColumns = `${startFrOf(startRect ? startRect.width : 0, finalRect.width)}fr`;
46755
+ if (!closedContentSized) {
46756
+ // The height opens alongside the width (a closed column expandable
46757
+ // is only as tall as its UI) — unless the closed content already
46758
+ // sizes it, where only the width has anywhere to go.
46759
+ contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
46760
+ }
46761
+ } else {
46762
+ contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
46763
+ }
46764
+ // That starting frame must be genuinely rendered to transition from it,
46765
+ // and transitions re-enabled BEFORE the flip back to the open value —
46766
+ // same order as popover.jsx's own reflow trick.
46767
+ contentContainer.getBoundingClientRect();
46768
+ contentContainer.style.transitionProperty = "";
46769
+ contentContainer.style.gridTemplateColumns = "";
46770
+ contentContainer.style.gridTemplateRows = "";
46771
+ }
46772
+ // (closing froze the content in toggleTo, while it was still laid out)
46773
+ const cancel = whenTransitionSettles(contentContainer, () => {
46774
+ contentElement.style.width = "";
46775
+ contentElement.style.height = "";
46557
46776
  setSettled(true);
46558
46777
  });
46559
46778
  return cancel;
@@ -46569,6 +46788,30 @@ const Expandable = props => {
46569
46788
  }
46570
46789
  }, [mountWhenClosed]);
46571
46790
 
46791
+ // closedContentSized (column + mountWhenClosed): the closed content sizes
46792
+ // the height (see the CSS), which is only right if it lies at its OPEN
46793
+ // width — at its natural closed width (a 0-wide track) it would wrap
46794
+ // against nothing and stack word by word. So while closed, its width is
46795
+ // frozen to a silently measured open width.
46796
+ useLayoutEffect(() => {
46797
+ if (!closedContentSized || opened || !settled || !contentMounted) {
46798
+ return;
46799
+ }
46800
+ const contentContainer = contentContainerRef.current;
46801
+ const contentElement = contentContainer.firstElementChild.firstElementChild;
46802
+ contentContainer.style.transitionProperty = "none";
46803
+ contentContainer.style.gridTemplateColumns = "1fr";
46804
+ contentElement.style.width = "";
46805
+ const openRect = contentElement.getBoundingClientRect();
46806
+ contentElement.style.width = `${openRect.width}px`;
46807
+ contentContainer.style.gridTemplateColumns = "";
46808
+ // The closed frame must be committed while transitions are still off —
46809
+ // re-enabled in the same recalc, the 1fr-to-0fr trip back from the silent
46810
+ // measurement above would play as a second closing animation.
46811
+ contentContainer.getBoundingClientRect();
46812
+ contentContainer.style.transitionProperty = "";
46813
+ }, [closedContentSized, opened, settled, contentMounted]);
46814
+
46572
46815
  // Mounted already open: the content is visible, its data is due.
46573
46816
  useEffect(() => {
46574
46817
  if (openedRef.current && hasAction) {
@@ -46594,7 +46837,7 @@ const Expandable = props => {
46594
46837
  key
46595
46838
  } = keyboardEvent;
46596
46839
  if (key === openKeyShortcut) {
46597
- if (document.activeElement !== toggleButtonRef.current) {
46840
+ if (document.activeElement !== uiRef.current) {
46598
46841
  return;
46599
46842
  }
46600
46843
  if (!openedRef.current) {
@@ -46614,19 +46857,19 @@ const Expandable = props => {
46614
46857
  if (!openedRef.current) {
46615
46858
  return;
46616
46859
  }
46617
- const toggleButton = toggleButtonRef.current;
46618
- if (document.activeElement === toggleButton) {
46860
+ const uiElement = uiRef.current;
46861
+ if (document.activeElement === uiElement) {
46619
46862
  keyboardEvent.preventDefault();
46620
46863
  toggleTo(false);
46621
46864
  } else {
46622
46865
  keyboardEvent.preventDefault();
46623
- toggleButton.focus();
46866
+ uiElement.focus();
46624
46867
  }
46625
46868
  }
46626
46869
  };
46627
46870
  const onUIClick = clickEvent => {
46628
46871
  // 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).
46872
+ // click_to_expand.js — the UI part's own aria-expanded is what it finds).
46630
46873
  if (clickEvent.defaultPrevented) {
46631
46874
  return;
46632
46875
  }
@@ -46635,32 +46878,62 @@ const Expandable = props => {
46635
46878
  } = clickEvent;
46636
46879
  if (target.nodeType === 1) {
46637
46880
  const interactiveElement = target.closest(UI_INTERACTIVE_SELECTOR);
46638
- if (interactiveElement && interactiveElement !== toggleButtonRef.current && uiRef.current.contains(interactiveElement)) {
46881
+ if (interactiveElement && interactiveElement !== uiRef.current && uiRef.current.contains(interactiveElement)) {
46639
46882
  return;
46640
46883
  }
46641
46884
  }
46642
46885
  toggleTo(!openedRef.current);
46643
46886
  };
46887
+
46888
+ // Space/Enter on the UI part itself (role button) — a key pressed on a
46889
+ // control inside it belongs to that control.
46890
+ const onUIKeyDown = keyboardEvent => {
46891
+ if (keyboardEvent.defaultPrevented) {
46892
+ return;
46893
+ }
46894
+ if (keyboardEvent.target !== uiRef.current) {
46895
+ return;
46896
+ }
46897
+ const {
46898
+ key
46899
+ } = keyboardEvent;
46900
+ if (key === " " || key === "Enter") {
46901
+ keyboardEvent.preventDefault();
46902
+ toggleTo(!openedRef.current);
46903
+ }
46904
+ };
46905
+
46906
+ // Where the content went, so the marker can point at it while open (closed
46907
+ // always points right): below by default, above when the content part comes
46908
+ // first, beside for layout="column" (the chevron then points back toward
46909
+ // the UI: left).
46910
+ const childArray = toChildArray(children);
46911
+ const firstPart = childArray.find(child => child && (child.type === ExpandableUI || child.type === ExpandableContent));
46912
+ const hasParts = Boolean(firstPart);
46913
+ const contentFirst = hasParts && firstPart.type === ExpandableContent;
46914
+ const openDirection = isColumn ? "left" : contentFirst ? "up" : "down";
46644
46915
  const expandableContextValue = {
46645
46916
  opened,
46646
46917
  loading: loading || hasAction && actionLoading,
46647
46918
  contentMounted,
46648
46919
  hasAction,
46649
46920
  effectiveAction,
46921
+ openDirection,
46922
+ toggleTo,
46650
46923
  onUIClick,
46651
46924
  onUIPointerDown,
46925
+ onUIKeyDown,
46652
46926
  uiRef,
46653
- toggleButtonRef,
46654
46927
  contentContainerRef,
46655
- contentId,
46656
- uiId
46928
+ contentId
46657
46929
  };
46658
46930
 
46659
46931
  // 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, {
46932
+ // common shape (UI above, content below). Parts are cloned on every render:
46933
+ // reference-stable children would be bailed out of the commit, leaving
46934
+ // their context subscription to re-render them asynchronously after the
46935
+ // [opened] effect above, which measures the content they render.
46936
+ const body = hasParts ? childArray.map(child => child && (child.type === ExpandableUI || child.type === ExpandableContent) ? cloneElement(child) : child) : jsxs(Fragment$1, {
46664
46937
  children: [jsx(ExpandableUI, {
46665
46938
  children: ui
46666
46939
  }), jsx(ExpandableContent, {
@@ -46671,10 +46944,12 @@ const Expandable = props => {
46671
46944
  ref: rootRef,
46672
46945
  baseClassName: "navi_expandable",
46673
46946
  "aria-expanded": opened ? "true" : "false",
46674
- "data-layout": layout === "column" ? "column" : undefined,
46947
+ "data-layout": isColumn ? "column" : undefined,
46948
+ "data-content-first": contentFirst ? "" : undefined,
46675
46949
  "data-animation": animation ? "" : undefined,
46676
46950
  "data-settled": settled ? "" : undefined,
46677
46951
  "data-content-scrolls": maxContentHeight === undefined ? undefined : "",
46952
+ "data-closed-content-sized": closedContentSized ? "" : undefined,
46678
46953
  ...rest,
46679
46954
  // The protocol every command target answers (see commands.js): a
46680
46955
  // `--navi-toggle`/`--navi-open`/`--navi-close` lands here as a
@@ -46707,10 +46982,12 @@ const Expandable = props => {
46707
46982
  };
46708
46983
 
46709
46984
  /**
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).
46985
+ * The always-visible part that reveals the content: the focusable toggle
46986
+ * itself (role buttonclick, Space/Enter, arrow keys), holding the marker
46987
+ * plus whatever it is given any markup, a function of `{ open }` included.
46988
+ * Controls inside it keep their own behavior and do not toggle. Its position
46989
+ * among the parts decides where the content goes (before the content: content
46990
+ * below/right; after it: content above/left).
46714
46991
  *
46715
46992
  * @type {import("ignore:preact").FunctionComponent<{
46716
46993
  * children?: import("ignore:preact").ComponentChildren | ((state: { open: boolean }) => import("ignore:preact").ComponentChildren),
@@ -46723,32 +47000,50 @@ const ExpandableUI = ({
46723
47000
  const {
46724
47001
  opened,
46725
47002
  loading,
47003
+ openDirection,
47004
+ toggleTo,
46726
47005
  onUIClick,
46727
47006
  onUIPointerDown,
47007
+ onUIKeyDown,
46728
47008
  uiRef,
46729
- toggleButtonRef,
46730
- contentId,
46731
- uiId
47009
+ contentId
46732
47010
  } = useExpandableContext("UI");
46733
47011
  return jsxs("div", {
46734
47012
  ref: uiRef,
46735
47013
  className: "navi_expandable_ui",
47014
+ role: "button",
47015
+ tabIndex: 0,
47016
+ "aria-expanded": opened,
47017
+ "aria-controls": contentId,
46736
47018
  onClick: onUIClick,
46737
47019
  onPointerDown: onUIPointerDown,
47020
+ onKeyDown: onUIKeyDown
47021
+ // A command from a control inside the UI part resolves its target to
47022
+ // the closest [aria-expanded] — this very element (see commands.js's
47023
+ // resolveClosestExpandable) — so it answers the protocol too. Spread as
47024
+ // an object: eslint's known-DOM-property check doesn't apply to navi's
47025
+ // own custom events.
47026
+ ,
47027
+
47028
+ onnavi_command: e => {
47029
+ onNaviCommand(e);
47030
+ },
47031
+ onnavi_request_open: () => {
47032
+ toggleTo(true);
47033
+ },
47034
+ onnavi_request_close: () => {
47035
+ toggleTo(false);
47036
+ },
46738
47037
  ...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,
47038
+ children: [jsx("span", {
47039
+ className: "navi_expandable_marker",
47040
+ "aria-hidden": "true",
46746
47041
  children: jsx(SummaryMarker, {
46747
47042
  open: opened,
46748
- loading: loading
47043
+ loading: loading,
47044
+ openDirection: openDirection
46749
47045
  })
46750
47046
  }), jsx("div", {
46751
- id: uiId,
46752
47047
  className: "navi_expandable_ui_label",
46753
47048
  children: typeof children === "function" ? children({
46754
47049
  open: opened
@@ -46789,8 +47084,11 @@ const ExpandableContent = ({
46789
47084
  inert: opened ? undefined : true,
46790
47085
  ...rest,
46791
47086
  children: jsx("div", {
46792
- className: "navi_expandable_content",
46793
- children: contentMounted ? content : null
47087
+ className: "navi_expandable_content_sizer",
47088
+ children: jsx("div", {
47089
+ className: "navi_expandable_content",
47090
+ children: contentMounted ? content : null
47091
+ })
46794
47092
  })
46795
47093
  });
46796
47094
  };
@@ -46798,8 +47096,8 @@ Expandable.UI = ExpandableUI;
46798
47096
  Expandable.Content = ExpandableContent;
46799
47097
 
46800
47098
  // 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.
47099
+ // control, not at the row. The UI part itself matches [role='button'] and is
47100
+ // the one exception, excluded at the call site.
46803
47101
  const UI_INTERACTIVE_SELECTOR = ["a[href]", "button", "input", "select", "textarea", "label", "[role='button']", "[contenteditable='']", "[contenteditable='true']", "audio[controls]", "video[controls]"].join(", ");
46804
47102
  const createToggleEvent = open => {
46805
47103
  const newState = open ? "open" : "closed";
@@ -46884,7 +47182,8 @@ const ControlGroup = props => {
46884
47182
  stateType: "object",
46885
47183
  cascadeValidationToChildren: true,
46886
47184
  aggregateChildStates: props.aggregateChildStates,
46887
- distributeChildUIState: props.distributeChildUIState
47185
+ distributeChildUIState: props.distributeChildUIState,
47186
+ distributeChildStates: props.distributeChildStates
46888
47187
  });
46889
47188
  const {
46890
47189
  children
@@ -46898,6 +47197,7 @@ const ControlGroup = props => {
46898
47197
  ,
46899
47198
  aggregateChildStates: undefined,
46900
47199
  distributeChildUIState: undefined,
47200
+ distributeChildStates: undefined,
46901
47201
  pseudoClasses: CONTROL_GROUP_PSEUDO_CLASSES,
46902
47202
  children: jsx(ControlgroupChildrenWrapper, {
46903
47203
  ...childrenWrapperProps,
@@ -50335,7 +50635,7 @@ const seedDefaultValueFromSignal = (props) => {
50335
50635
  }
50336
50636
  };
50337
50637
 
50338
- const resolveInputProps = (props) => {
50638
+ const resolveInputProps = (props, { controlType = "input" } = {}) => {
50339
50639
  // `signal` carries a bound state signal. It is left on `props` on purpose:
50340
50640
  // `createControlInfo` (control_hooks.jsx) reads it to seed the state and to
50341
50641
  // follow it, and `onUIAction` (ui_state_controller.js) writes user
@@ -50352,8 +50652,20 @@ const resolveInputProps = (props) => {
50352
50652
  }
50353
50653
  }
50354
50654
  if (props.type === undefined && signalOptions.type !== undefined) {
50355
- props.type =
50655
+ const typeFromSignal =
50356
50656
  VALIDITY_TYPE_TO_INPUT_TYPE[signalOptions.type] ?? signalOptions.type;
50657
+ // What a signal says is what its value IS; what a control's `type` says
50658
+ // is what the control is. They usually agree — a date-typed signal wants
50659
+ // a date field — but a boolean one maps to a checkbox, and a picker made
50660
+ // into a checkbox is not a picker with a different look: it is another
50661
+ // control, with no popup to open. A picker asked to hold a yes/no keeps
50662
+ // its two rows and stays itself.
50663
+ const wouldChangeWhatTheControlIs =
50664
+ controlType === "picker" &&
50665
+ (typeFromSignal === "checkbox" || typeFromSignal === "radio");
50666
+ if (!wouldChangeWhatTheControlIs) {
50667
+ props.type = typeFromSignal;
50668
+ }
50357
50669
  }
50358
50670
  }
50359
50671
 
@@ -55179,6 +55491,20 @@ const PickerCustomResolver = props => {
55179
55491
  ...props
55180
55492
  });
55181
55493
  }
55494
+ if (props.type === undefined) {
55495
+ // A picker with a popup of its own holds whatever the control inside it
55496
+ // holds — a boolean, a number, an id — and a field with no type is read
55497
+ // back off the DOM, where every value is a string. "false" then matches no
55498
+ // row, the popup empties, and that emptiness climbs back into the picker:
55499
+ // a value survives its own round trip only while it is text. "navi_js" is
55500
+ // how a field says its value is a JS one, kept beside the DOM (see
55501
+ // controller_registry.js) — the same thing type="array"/"object" already
55502
+ // say for their shapes.
55503
+ return jsx(PickerCustom, {
55504
+ ...props,
55505
+ type: "navi_js"
55506
+ });
55507
+ }
55182
55508
  return jsx(PickerCustom, {
55183
55509
  ...props
55184
55510
  });
@@ -62190,7 +62516,7 @@ const PickerObjectUI = () => {
62190
62516
  value,
62191
62517
  placeholder
62192
62518
  } = useContext(PickerContext);
62193
- if (!value || Object.keys(value).length === 0) {
62519
+ if (uiStateHoldsNothing(value)) {
62194
62520
  if (!placeholder) {
62195
62521
  return null;
62196
62522
  }
@@ -62227,7 +62553,7 @@ const PickerArrayUI = () => {
62227
62553
  placeholder,
62228
62554
  maxLines
62229
62555
  } = useContext(PickerContext);
62230
- if (!value || value.length === 0) {
62556
+ if (uiStateHoldsNothing(value)) {
62231
62557
  if (!placeholder) {
62232
62558
  return null;
62233
62559
  }
@@ -63259,7 +63585,9 @@ const PickerFirstResolver = props => {
63259
63585
  const Next = useNextResolver();
63260
63586
  const defaultRef = useRef(null);
63261
63587
  props.ref = props.ref || defaultRef;
63262
- resolveInputProps(props);
63588
+ resolveInputProps(props, {
63589
+ controlType: "picker"
63590
+ });
63263
63591
  return jsx(Next, {
63264
63592
  ...props
63265
63593
  });
@@ -64335,7 +64663,8 @@ const SpinGroup = props => {
64335
64663
  stateType: "object",
64336
64664
  cascadeValidationToChildren: true,
64337
64665
  aggregateChildStates: props.aggregateChildStates,
64338
- distributeChildUIState: props.distributeChildUIState
64666
+ distributeChildUIState: props.distributeChildUIState,
64667
+ distributeChildStates: props.distributeChildStates
64339
64668
  });
64340
64669
  const {
64341
64670
  children
@@ -64352,6 +64681,7 @@ const SpinGroup = props => {
64352
64681
  // not reach the DOM as unknown attributes.
64353
64682
  aggregateChildStates: undefined,
64354
64683
  distributeChildUIState: undefined,
64684
+ distributeChildStates: undefined,
64355
64685
  baseClassName: "navi_spin_group",
64356
64686
  pseudoClasses: SPIN_GROUP_PSEUDO_CLASSES
64357
64687
  // What the frame and what sits between the spins are drawn from: the
@@ -68863,7 +69193,8 @@ const WheelGroup = props => {
68863
69193
  stateType: "object",
68864
69194
  cascadeValidationToChildren: true,
68865
69195
  aggregateChildStates: props.aggregateChildStates,
68866
- distributeChildUIState: props.distributeChildUIState
69196
+ distributeChildUIState: props.distributeChildUIState,
69197
+ distributeChildStates: props.distributeChildStates
68867
69198
  });
68868
69199
  const {
68869
69200
  children
@@ -68922,6 +69253,7 @@ const WheelGroup = props => {
68922
69253
  zoom: undefined,
68923
69254
  aggregateChildStates: undefined,
68924
69255
  distributeChildUIState: undefined,
69256
+ distributeChildStates: undefined,
68925
69257
  baseClassName: "navi_wheel_group",
68926
69258
  "data-horizontal": horizontal ? "" : undefined,
68927
69259
  style: groupStyle,