@jsenv/navi 0.29.472 → 0.29.474

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.
@@ -18934,6 +18934,20 @@ const openCallout = (
18934
18934
  // deduped away on the grounds that the anchor did not move.
18935
18935
  callout.updatePosition = () =>
18936
18936
  positioner.update(new CustomEvent(ELEMENT_SIZE_CHANGE));
18937
+ // Content that sizes itself once on screen (a shrink-wrapped BadgeList) does
18938
+ // so after the callout measured it. The size observer positionCallout puts
18939
+ // on the message sees the change, but answers a frame later (see observeSize
18940
+ // in @jsenv/dom for why); placed again on the spot, the first painted frame
18941
+ // is the right one. Stopped here, since a popup further up holds the callout
18942
+ // out of flow and does not resize with it.
18943
+ const onContentSizeChange = (sizeChangeEvent) => {
18944
+ sizeChangeEvent.stopPropagation();
18945
+ callout.updatePosition();
18946
+ };
18947
+ calloutElement.addEventListener("navi_size_change", onContentSizeChange);
18948
+ addTeardown(() => {
18949
+ calloutElement.removeEventListener("navi_size_change", onContentSizeChange);
18950
+ });
18937
18951
 
18938
18952
  return callout;
18939
18953
  };
@@ -43943,6 +43957,7 @@ const useRenderScope = (init, update) => {
43943
43957
  * registerChild(child): void; // Called on child mount
43944
43958
  * onChildUIAction(child, e, { stateChanged: boolean }): void; // Called when a child fires a UI action
43945
43959
  * unregisterChild(child): void; // Called on child unmount
43960
+ * collectsChild(child): boolean; // Whether registerChild(child) would put it in a value, here or above
43946
43961
  * props: Object;
43947
43962
  * ref: Ref; // Used to dispatch DOM events
43948
43963
  * getManagedControls(): UIStateController[]; // Returns controls whose validity is managed by this controller
@@ -44667,6 +44682,10 @@ const useUIStateController = (
44667
44682
  unregisterChild: (childUIStateController) => {
44668
44683
  s.parentUIStateController?.unregisterChild(childUIStateController);
44669
44684
  },
44685
+ collectsChild: (childUIStateController) =>
44686
+ Boolean(
44687
+ s.parentUIStateController?.collectsChild(childUIStateController),
44688
+ ),
44670
44689
  onChildUIAction: (childUIStateController, e, options) => {
44671
44690
  s.parentUIStateController?.onChildUIAction(
44672
44691
  childUIStateController,
@@ -45053,10 +45072,13 @@ const readNamedChildUIStates = (children, { warnNameless }) => {
45053
45072
  // Looked up in useUIGroupStateController to fill in omitted aggregateChildStates /
45054
45073
  // distributeChildUIState. If neither a default nor an explicit impl is found for a
45055
45074
  // group, creation throws so the caller knows it must supply them.
45075
+ // The radio/checkbox filters read `props.type`, which controlHostProps copies:
45076
+ // a child is also asked about while rendering, before its first render has
45077
+ // built controlHostProps (see collectsChild).
45056
45078
  const GROUP_DEFAULTS = {
45057
45079
  radio_group: {
45058
45080
  childControlFilter: (child) =>
45059
- child.controlType === "input" && child.controlHostProps?.type === "radio",
45081
+ child.controlType === "input" && child.props?.type === "radio",
45060
45082
  aggregateChildStates: keptOrFirstDefinedChildUIState,
45061
45083
  distributeChildUIState: (newUIState, childUIStateController) => {
45062
45084
  const childSelected = childUIStateController.props.value === newUIState;
@@ -45068,8 +45090,7 @@ const GROUP_DEFAULTS = {
45068
45090
  },
45069
45091
  checkbox_group: {
45070
45092
  childControlFilter: (child) =>
45071
- child.controlType === "input" &&
45072
- child.controlHostProps?.type === "checkbox",
45093
+ child.controlType === "input" && child.props?.type === "checkbox",
45073
45094
  aggregateChildStates: keptAndCheckedChildUIStates,
45074
45095
  distributeChildUIState: (newUIState, childUIStateController) => {
45075
45096
  const childSelected =
@@ -45614,6 +45635,9 @@ const useUIGroupStateController = (
45614
45635
 
45615
45636
  const controller = {
45616
45637
  controlType,
45638
+ // Published from the first render, like a leaf's: a group asks its
45639
+ // parent whether anyone listens before its first update.
45640
+ parentUIStateController,
45617
45641
  id,
45618
45642
  name,
45619
45643
  value,
@@ -45837,6 +45861,18 @@ const useUIGroupStateController = (
45837
45861
  notifyExternal: "silent",
45838
45862
  });
45839
45863
  },
45864
+ // registerChild above, asked ahead: monitored here, handed up, or
45865
+ // captured and dropped.
45866
+ collectsChild: (childUIStateController) => {
45867
+ if (isMonitoringChild(childUIStateController)) {
45868
+ return true;
45869
+ }
45870
+ const currentParent = s.parentUIStateController;
45871
+ if (allowCapture || !currentParent) {
45872
+ return false;
45873
+ }
45874
+ return currentParent.collectsChild(childUIStateController);
45875
+ },
45840
45876
  onChildUIAction: (
45841
45877
  childUIStateController,
45842
45878
  e,
@@ -46314,6 +46350,9 @@ const useUIFacadeStateController = (props, realUIStateController) => {
46314
46350
  s.realUIStateController.facadeChild = null;
46315
46351
  }
46316
46352
  },
46353
+ // A refused child is not handed further up: the popup's controls
46354
+ // answer to the picker or to nobody.
46355
+ collectsChild: (child) => canRegisterAsFacadeChild(child),
46317
46356
  getManagedControls: () => {
46318
46357
  const child = firstChildControllerRef.current;
46319
46358
  if (!child) {
@@ -46934,8 +46973,6 @@ const useControlProps = (props, {
46934
46973
  const controlInfo = createControlInfo(props, {
46935
46974
  controlType
46936
46975
  });
46937
- const readOnlyUncontrolled = useReadOnlyUncontrolled(props, controlInfo);
46938
- controlInfo.readOnlyUncontrolled = readOnlyUncontrolled;
46939
46976
  const uiStateController = useUIStateController(props, {
46940
46977
  controlInfo,
46941
46978
  syncDomState,
@@ -46943,6 +46980,9 @@ const useControlProps = (props, {
46943
46980
  persists,
46944
46981
  uiActionInternal
46945
46982
  });
46983
+ // After the controller: whether a group listens is asked of the parent it
46984
+ // registers into. Read by useInteractiveProps below.
46985
+ controlInfo.readOnlyUncontrolled = useReadOnlyUncontrolled(props, controlInfo, uiStateController);
46946
46986
  // What the control draws is what it holds by itself, and this read is what
46947
46987
  // re-renders it when that changes. A button inheriting the value of the
46948
46988
  // control around it (a submit in a form, see ownUIStateSignal) draws none of
@@ -47948,16 +47988,21 @@ const INPUT_TYPE_SUPPORTING_READONLY_SET = new Set(["text", "date", "datetime-lo
47948
47988
  // warning below so a group can ask the same question a single control does: a
47949
47989
  // control with a `value` and nobody listening cannot be changed by hand, and
47950
47990
  // that is true whatever the control is.
47951
- const useIsControlListenedTo = props => {
47991
+ // The group is asked, not assumed from a controller being above: a control in
47992
+ // between may only pass registration along (a <Details> holds none of the
47993
+ // values it folds), and a group may hand the control further up or drop it.
47994
+ const useIsControlListenedTo = (props, uiStateController) => {
47952
47995
  const isProxy = Boolean(props["navi-control-proxy-for"]);
47953
47996
  const formContext = useContext(FormContext);
47954
- const parentUIStateController = useContext(ParentUIStateControllerContext);
47997
+ const {
47998
+ parentUIStateController
47999
+ } = uiStateController;
47955
48000
  return Boolean(props.signal ||
47956
48001
  // a bound signal is written back on uiAction → interactive
47957
- props.uiAction || props.action || formContext || parentUIStateController || isProxy || props.command);
48002
+ props.uiAction || props.action || formContext || parentUIStateController?.collectsChild(uiStateController) || isProxy || props.command);
47958
48003
  };
47959
- const useReadOnlyUncontrolled = (props, controlInfo) => {
47960
- const listenedTo = useIsControlListenedTo(props);
48004
+ const useReadOnlyUncontrolled = (props, controlInfo, uiStateController) => {
48005
+ const listenedTo = useIsControlListenedTo(props, uiStateController);
47961
48006
  const warnedRef = useRef(false);
47962
48007
  if (!controlInfo.hasStateProp) {
47963
48008
  return false;
@@ -48023,9 +48068,9 @@ const useControlgroupProps = (props, {
48023
48068
  const [boundAction] = useActionBoundToOneParam(action, uiGroupStateController.uiStateSignal);
48024
48069
  // Mirror single-input behaviour: a controlled value with nobody listening
48025
48070
  // makes the group read-only so children don't appear interactive when they
48026
- // can't change. A form or a group around it IS someone listening — that is
48027
- // what will send the value and hand a new one back.
48028
- const listenedTo = useIsControlListenedTo(props);
48071
+ // can't change. A form or a group collecting it IS someone listening — that
48072
+ // is what will send the value and hand a new one back.
48073
+ const listenedTo = useIsControlListenedTo(props, uiGroupStateController);
48029
48074
  const implicitReadOnlyWarnedRef = useRef(false);
48030
48075
  const implicitReadOnly = uiGroupStateController.hasValueProp && !listenedTo;
48031
48076
  if (implicitReadOnly && !props.readOnly) {
@@ -71552,6 +71597,15 @@ const useDialogProps = props => {
71552
71597
  freezeSize(dialogEl);
71553
71598
  }
71554
71599
  rectEffect.observeSize(dialogEl);
71600
+ // Content that sizes itself once on screen (a shrink-wrapped BadgeList)
71601
+ // says so, after the placement read it: placed again on the spot, where
71602
+ // observeSize answers a frame late. Stopped here, since a popup further up
71603
+ // holds this one out of flow and does not resize with it.
71604
+ const onContentSizeChange = sizeChangeEvent => {
71605
+ sizeChangeEvent.stopPropagation();
71606
+ rectEffect.check(new CustomEvent(ELEMENT_SIZE_CHANGE));
71607
+ };
71608
+ dialogEl.addEventListener("navi_size_change", onContentSizeChange);
71555
71609
  // Exposed for the placement-props effect below, which needs to re-place an
71556
71610
  // already-open dialog.
71557
71611
  repositionRef.current = repositionEvent => {
@@ -71565,6 +71619,7 @@ const useDialogProps = props => {
71565
71619
  };
71566
71620
  addCleanup(() => {
71567
71621
  repositionRef.current = null;
71622
+ dialogEl.removeEventListener("navi_size_change", onContentSizeChange);
71568
71623
  rectEffect.disconnect();
71569
71624
  });
71570
71625
  // A descendant anchored to something inside this dialog (a Callout, a
@@ -73395,6 +73450,18 @@ const usePopoverProps = props => {
73395
73450
  // while open (e.g. an expand/collapse toggle inside it) — not just when
73396
73451
  // the anchor itself moves/resizes/re-anchors.
73397
73452
  rectEffect.observeSize(popoverEl);
73453
+ // Content that sizes itself once on screen (a shrink-wrapped BadgeList)
73454
+ // says so, after the placement read it: placed again on the spot, where
73455
+ // observeSize answers a frame late. Stopped here, since a popup further up
73456
+ // holds this one out of flow and does not resize with it.
73457
+ const onContentSizeChange = sizeChangeEvent => {
73458
+ sizeChangeEvent.stopPropagation();
73459
+ rectEffect.check(new CustomEvent(ELEMENT_SIZE_CHANGE));
73460
+ };
73461
+ popoverEl.addEventListener("navi_size_change", onContentSizeChange);
73462
+ addCleanup(() => {
73463
+ popoverEl.removeEventListener("navi_size_change", onContentSizeChange);
73464
+ });
73398
73465
  // Exposed for the placement-props effect above, which needs to re-place an
73399
73466
  // already-open popover.
73400
73467
  repositionRef.current = repositionEvent => {
@@ -74449,8 +74516,15 @@ const PickerCustom = props => {
74449
74516
  // moment both exist. A picker that picks nothing holds none: its popup's
74450
74517
  // controls are never adopted (see canRegisterAsFacadeChild).
74451
74518
  const valueInPopup = !props.picksNothing && !isControlValueGivenByProps(props);
74519
+ // Asked of the chain, not read off the context: a control above may only
74520
+ // pass registration along (a <Details> holds none of the values it folds),
74521
+ // and a group may hand a picker further up or drop it. "picker" is what the
74522
+ // picker's own controller registers as (see picker.jsx).
74452
74523
  const parentController = useContext(ParentUIStateControllerContext);
74453
- const answersToGroup = Boolean(parentController) && !props.standalone;
74524
+ const answersToGroup = !props.standalone && Boolean(parentController?.collectsChild({
74525
+ controlType: "picker",
74526
+ props
74527
+ }));
74454
74528
  // Same narrow-container/maxWidth-compact heuristic Popup itself uses (see
74455
74529
  // popup_mode.jsx's own useResolvedPopupMode) — frozen for the lifetime of an opening
74456
74530
  // (computed when closed, stable while open, so a screen resize mid-session
@@ -85041,39 +85115,29 @@ const BadgeListShrinkWrap = ({
85041
85115
  const registry = useBadgeRegistry(children, max !== undefined || fallback !== undefined);
85042
85116
  const measureRef = useRef();
85043
85117
  const visibleRef = useRef();
85118
+
85119
+ // In a closed popup the rows have no size to read: the list is measured when
85120
+ // it comes on screen, before the frame is painted.
85121
+ useDisplayedLayoutEffect(visibleRef, () => {
85122
+ shrinkWrapToWidestRow(visibleRef.current, measureRef.current);
85123
+ }, [children]);
85044
85124
  useLayoutEffect(() => {
85045
85125
  const measureEl = measureRef.current;
85046
85126
  const visibleEl = visibleRef.current;
85047
85127
  if (!measureEl || !visibleEl) {
85048
85128
  return undefined;
85049
85129
  }
85050
- let observer;
85051
- let rafId;
85052
- const measure = () => {
85053
- visibleEl.style.width = "";
85054
- // Clone the already-rendered DOM nodes instead of letting React/Preact
85055
- // render the children a second time into the ghost: re-rendering would
85056
- // instantiate Badge/Badge.Button a second time, double-registering their
85057
- // controllers (and any other mount side effect) under the same id.
85058
- measureEl.replaceChildren(...Array.from(visibleEl.children, child => child.cloneNode(true)));
85059
- const optimalWidth = measureWidestChildRow(measureEl);
85060
- if (optimalWidth !== null) {
85061
- visibleEl.style.width = `${Math.ceil(optimalWidth)}px`;
85062
- }
85063
- };
85064
-
85065
- // A single badge is a single row, and a single row is already the widest
85066
- // one: there is nothing to narrow, and no width the list could be given
85067
- // that would change that.
85130
+ // A single row whatever room it is given: nothing a resize could narrow.
85068
85131
  if (visibleEl.children.length < 2) {
85069
- visibleEl.style.width = "";
85070
- measureEl.replaceChildren();
85071
85132
  return undefined;
85072
85133
  }
85073
- measure();
85134
+ let observer;
85135
+ let rafId;
85074
85136
  const onResize = () => {
85075
85137
  cancelAnimationFrame(rafId);
85076
- rafId = requestAnimationFrame(measure);
85138
+ rafId = requestAnimationFrame(() => {
85139
+ shrinkWrapToWidestRow(visibleEl, measureEl);
85140
+ });
85077
85141
  };
85078
85142
  const outerParent = measureEl.parentElement?.parentElement;
85079
85143
  if (outerParent) {
@@ -85119,6 +85183,40 @@ const BadgeListShrinkWrap = ({
85119
85183
  })
85120
85184
  );
85121
85185
  };
85186
+
85187
+ // Narrows the list to its widest row, read from clones of its badges in the
85188
+ // ghost.
85189
+ //
85190
+ // What holds the list may have sized itself around the width it had before —
85191
+ // a callout pins its body to the width it measured — so a change is announced
85192
+ // with a bubbling navi_size_change for it to measure again, in the same frame.
85193
+ const shrinkWrapToWidestRow = (visibleEl, measureEl) => {
85194
+ // Not drawn (back in a closed popup): no row to read. The width is kept for
85195
+ // the next opening, which measures again before painting.
85196
+ if (visibleEl.getClientRects().length === 0) {
85197
+ return;
85198
+ }
85199
+ const widthBefore = visibleEl.style.width;
85200
+ visibleEl.style.width = "";
85201
+ if (visibleEl.children.length < 2) {
85202
+ // A single badge is a single row, and a single row is already the widest
85203
+ // one: there is nothing to narrow.
85204
+ measureEl.replaceChildren();
85205
+ } else {
85206
+ // Clone the already-rendered DOM nodes instead of letting React/Preact
85207
+ // render the children a second time into the ghost: re-rendering would
85208
+ // instantiate Badge/Badge.Button a second time, double-registering their
85209
+ // controllers (and any other mount side effect) under the same id.
85210
+ measureEl.replaceChildren(...Array.from(visibleEl.children, child => child.cloneNode(true)));
85211
+ const optimalWidth = measureWidestChildRow(measureEl);
85212
+ if (optimalWidth !== null) {
85213
+ visibleEl.style.width = `${Math.ceil(optimalWidth)}px`;
85214
+ }
85215
+ }
85216
+ if (visibleEl.style.width !== widthBefore) {
85217
+ dispatchPublicCustomEvent(visibleEl, "navi_size_change");
85218
+ }
85219
+ };
85122
85220
  const BadgeListMaxLines = ({
85123
85221
  fallback,
85124
85222
  children,