@jsenv/navi 0.27.66 → 0.27.68

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.
@@ -17418,7 +17418,7 @@ const createCalloutManager = (
17418
17418
  return;
17419
17419
  }
17420
17420
  const resolvedAnchorElement =
17421
- anchorElement || controller.elementRef.current;
17421
+ anchorElement || controller.ref.current;
17422
17422
  const removeCloseOnCleanup = addTeardown?.(() => {
17423
17423
  requestCloseCallout(new CustomEvent("cleanup"), "cleanup");
17424
17424
  });
@@ -17443,7 +17443,7 @@ const createCalloutManager = (
17443
17443
  tokenData.onClose?.();
17444
17444
  }
17445
17445
  tokens.clear();
17446
- const element = controller.elementRef.current;
17446
+ const element = controller.ref.current;
17447
17447
  if (
17448
17448
  shouldTransferFocusFromCallout &&
17449
17449
  element &&
@@ -19491,7 +19491,7 @@ const REQUIRED_CONSTRAINT = {
19491
19491
  }
19492
19492
  return {
19493
19493
  message: naviI18n("constraint.required.radio"),
19494
- target: field.elementRef.current,
19494
+ target: field.ref.current,
19495
19495
  };
19496
19496
  }
19497
19497
  if (type === "radio") {
@@ -19505,7 +19505,7 @@ const REQUIRED_CONSTRAINT = {
19505
19505
  }
19506
19506
  return {
19507
19507
  message: naviI18n("constraint.required.radio"),
19508
- target: field.elementRef.current,
19508
+ target: field.ref.current,
19509
19509
  };
19510
19510
  }
19511
19511
 
@@ -19517,7 +19517,7 @@ const REQUIRED_CONSTRAINT = {
19517
19517
  }
19518
19518
  return {
19519
19519
  message: naviI18n("constraint.required.checkbox_group"),
19520
- target: field.elementRef.current,
19520
+ target: field.ref.current,
19521
19521
  };
19522
19522
  }
19523
19523
  if (type === "checkbox") {
@@ -20356,7 +20356,7 @@ const createControlValidation = (
20356
20356
 
20357
20357
  const checkValidity = ({
20358
20358
  event,
20359
- requester = controller.elementRef.current,
20359
+ requester = controller.ref.current,
20360
20360
  fromRequestAction,
20361
20361
  } = {}) => {
20362
20362
  if (fromRequestAction) {
@@ -20399,7 +20399,7 @@ const createControlValidation = (
20399
20399
  ...DEFAULT_CONSTRAINT_SET,
20400
20400
  ...dynamicConstraintSet,
20401
20401
  ]);
20402
- const elementSig = getElementSignature(controller.elementRef.current);
20402
+ const elementSig = getElementSignature(controller.ref.current);
20403
20403
  debugUIState(
20404
20404
  event,
20405
20405
  `check ${elementSig}: ${constraintSet.size} constraints`,
@@ -20454,7 +20454,7 @@ const createControlValidation = (
20454
20454
  if (activeFailedConstraintInfo) {
20455
20455
  const titleLess = controller.controlHostProps.title === undefined;
20456
20456
  if (titleLess) {
20457
- const element = controller.elementRef.current;
20457
+ const element = controller.ref.current;
20458
20458
  if (element) {
20459
20459
  element.setAttribute(
20460
20460
  "title",
@@ -20465,7 +20465,7 @@ const createControlValidation = (
20465
20465
  } else {
20466
20466
  const titleLess = controller.controlHostProps.title === undefined;
20467
20467
  if (titleLess) {
20468
- const element = controller.elementRef.current;
20468
+ const element = controller.ref.current;
20469
20469
  if (element) {
20470
20470
  element.removeAttribute("title");
20471
20471
  }
@@ -20479,7 +20479,7 @@ const createControlValidation = (
20479
20479
  !compareTwoJsValues(constraintValidityState, newConstraintValidityState)
20480
20480
  ) {
20481
20481
  constraintValidityState = newConstraintValidityState;
20482
- const element = controller.elementRef.current;
20482
+ const element = controller.ref.current;
20483
20483
  if (element) {
20484
20484
  debugUIState(
20485
20485
  event,
@@ -20529,7 +20529,7 @@ const createControlValidation = (
20529
20529
  event,
20530
20530
  { report = false, fromRequestAction = false } = {},
20531
20531
  ) => {
20532
- const elementSig = getElementSignature(controller.elementRef.current);
20532
+ const elementSig = getElementSignature(controller.ref.current);
20533
20533
  const isValid = checkValidity({ event, fromRequestAction });
20534
20534
  if (failingManagedControlValidity) {
20535
20535
  // Group/form case: find the actual failing leaf and report on it.
@@ -21022,7 +21022,7 @@ const createControlInteraction = (
21022
21022
  // Keep title attribute in sync for accessibility.
21023
21023
  const titleLess = !controller.controlHostProps?.title;
21024
21024
  if (titleLess) {
21025
- const element = controller.elementRef.current;
21025
+ const element = controller.ref.current;
21026
21026
  if (element) {
21027
21027
  if (interactionFailedConstraintInfo) {
21028
21028
  element.setAttribute(
@@ -21236,7 +21236,7 @@ const tryActionAfterInteractionAllowed = (
21236
21236
  if (controller) {
21237
21237
  const proxyTargetController = findControlProxyTargetController(controller);
21238
21238
  if (proxyTargetController) {
21239
- elementForAction = proxyTargetController.elementRef.current;
21239
+ elementForAction = proxyTargetController.ref.current;
21240
21240
  }
21241
21241
  const activeController = proxyTargetController ?? controller;
21242
21242
  uiState = activeController?.uiState;
@@ -22344,6 +22344,36 @@ const createControlRules = (
22344
22344
  };
22345
22345
  };
22346
22346
 
22347
+ /**
22348
+ * Returns a stable object that is mutated across renders.
22349
+ * Closures that capture the returned reference always read current values
22350
+ * because the same object reference is reused — no stale captures.
22351
+ *
22352
+ * - `init(scope)` — called **once** on mount. Receives the (initially empty)
22353
+ * stable scope object and returns properties to assign to it. Use this to
22354
+ * create anything that must live for the component's full lifetime: signals,
22355
+ * pubsub pairs, the controller object itself, etc.
22356
+ *
22357
+ * - `update(scope)` — called on every subsequent render. Receives the current
22358
+ * scope and returns properties to update. Use this to sync mutable values
22359
+ * (current props, parent controller, etc.) and run side-effectful logic like
22360
+ * checking whether a controlled `state` prop changed.
22361
+ */
22362
+ const useRenderScope = (init, update) => {
22363
+ const scopeRef = useRef();
22364
+ let scope = scopeRef.current;
22365
+ if (!scope) {
22366
+ scope = {};
22367
+ scopeRef.current = scope;
22368
+ const initScope = init(scope);
22369
+ Object.assign(scope, initScope);
22370
+ } else {
22371
+ const updateScope = update(scope);
22372
+ Object.assign(scope, updateScope);
22373
+ }
22374
+ return scope;
22375
+ };
22376
+
22347
22377
  /**
22348
22378
  * Minimal interface that any object placed in `ParentUIStateControllerContext` must satisfy.
22349
22379
  * Implemented by `useUIGroupStateController`, `useUIFacadeStateController`, and
@@ -22359,7 +22389,7 @@ const createControlRules = (
22359
22389
  * onChildUIAction(child, e, { stateChanged: boolean }): void; // Called when a child fires a UI action
22360
22390
  * unregisterChild(child): void; // Called on child unmount
22361
22391
  * props: Object;
22362
- * elementRef: Ref; // Used to dispatch DOM events
22392
+ * ref: Ref; // Used to dispatch DOM events
22363
22393
  * getManagedControls(): UIStateController[]; // Returns controls whose validity is managed by this controller
22364
22394
  * }
22365
22395
  * ```
@@ -22395,7 +22425,7 @@ const ParentUIStateControllerContext = createContext();
22395
22425
  * **internalBehavior events** (e.g. radio_sibling_uncheck, state_prop re-sync):
22396
22426
  * skip reactions and parent notification — they are programmatic, not user-initiated.
22397
22427
  *
22398
- * The controller exposes `elementRef` so parent groups can dispatch DOM events on children
22428
+ * The controller exposes `ref` so parent groups can dispatch DOM events on children
22399
22429
  * (e.g. `resetUIState` cascading `navi_reset_ui_state`).
22400
22430
  */
22401
22431
  const useUIStateController = (
@@ -22413,522 +22443,512 @@ const useUIStateController = (
22413
22443
  const debugUIState = useDebugUIState();
22414
22444
  const debugFocus = useDebugFocus();
22415
22445
 
22416
- const uiStateControllerRef = useRef();
22417
22446
  const parentUIStateController = useContext(ParentUIStateControllerContext);
22418
22447
  const formContext = useContext(FormContext);
22419
- const { id, uiAction } = props;
22420
- const ref = props.ref;
22421
- const isProxy = Boolean(props["navi-control-proxy-for"]);
22422
22448
  if (persists === undefined && formContext) {
22423
22449
  persists = true;
22424
22450
  }
22425
22451
  const controlType = controlInfo.controlType;
22426
22452
  const isRadio = controlType === "input" && props.type === "radio";
22427
- const [
22428
- notifyParentAboutChildMount,
22429
- notifyParentAboutChildUIAction,
22430
- notifyParentAboutChildUnmount,
22431
- ] = useParentControllerNotifiers(
22432
- parentUIStateController,
22433
- uiStateControllerRef,
22434
- controlType,
22435
- debugUIState,
22436
- );
22437
- useLayoutEffect(() => {
22438
- const controller = uiStateControllerRef.current;
22439
- const el = ref.current;
22440
- if (el) {
22441
- el.__uiStateController__ = controller;
22442
- }
22443
- notifyParentAboutChildMount();
22444
- return () => {
22445
- if (el && el.__uiStateController__ === controller) {
22446
- delete el.__uiStateController__;
22447
- }
22448
- notifyParentAboutChildUnmount();
22449
- onUIStateControllerDestroyed(controller);
22450
- };
22451
- }, []);
22453
+ const isProxy = Boolean(props["navi-control-proxy-for"]);
22452
22454
 
22453
- const existingUIStateController = uiStateControllerRef.current;
22454
- if (existingUIStateController) {
22455
- existingUIStateController._checkForUpdates({
22456
- props,
22457
- controlInfo,
22458
- });
22459
- return existingUIStateController;
22460
- }
22461
- const { stateInitial } = controlInfo;
22462
- debugUIState(
22463
- `Creating "${controlType}" ui state controller - initial state:`,
22464
- JSON.stringify(stateInitial),
22465
- );
22466
- const [publishUIState, subscribeUIState] = createPubSub();
22467
- const ownUIStateSignal = signal(stateInitial);
22468
- const inherit =
22469
- controlType === "button" &&
22470
- !controlInfo.hasStateProp &&
22471
- parentUIStateController;
22472
- const uiStateSignal = inherit
22473
- ? computed(() => {
22474
- const parentUIState = parentUIStateController.uiStateSignal.value;
22475
- const ownUIState = ownUIStateSignal.value;
22476
- return ownUIState || parentUIState;
22477
- })
22478
- : ownUIStateSignal;
22455
+ const scope = useRenderScope(
22456
+ // ── init: runs once on mount ───────────────────────────────────────────
22457
+ // Creates the controller and all long-lived objects. Captures first-render
22458
+ // values for stable config (controlType, isRadio…); live values are read
22459
+ // through `s` which is always updated by `update` before any method call.
22460
+ (s) => {
22461
+ const parentUiStateSignalHolder = signal(
22462
+ parentUIStateController?.uiStateSignal ?? null,
22463
+ );
22464
+ const { stateInitial } = controlInfo;
22465
+ debugUIState(
22466
+ `Creating "${controlType}" ui state controller - initial state:`,
22467
+ JSON.stringify(stateInitial),
22468
+ );
22469
+ const [publishUIState, subscribeUIState] = createPubSub();
22470
+ const ownUIStateSignal = signal(stateInitial);
22471
+ const inherit =
22472
+ controlType === "button" &&
22473
+ !controlInfo.hasStateProp &&
22474
+ parentUIStateController;
22475
+ const uiStateSignal = inherit
22476
+ ? computed(() => {
22477
+ const parentSig = parentUiStateSignalHolder.value;
22478
+ const parentUIState = parentSig?.value;
22479
+ const ownUIState = ownUIStateSignal.value;
22480
+ return ownUIState || parentUIState;
22481
+ })
22482
+ : ownUIStateSignal;
22479
22483
 
22480
- const uiStateController = {
22481
- _checkForUpdates: ({ props, controlInfo }) => {
22482
- // Raw Preact props from the current render. These are the component's input props,
22483
- // not the resolved/curated host props. useInteractiveProps overwrites
22484
- // uiStateController.controlHostProps with the resolved subset on every render.
22485
- uiStateController.props = props;
22486
- uiStateController.id = props.id; // never suppoed to changed, not supported for now
22487
- uiStateController.name = props.name;
22484
+ const controller = {
22485
+ controlType,
22486
+ parentUIStateController,
22487
+ parentUiStateSignalHolder,
22488
+ isProxy,
22489
+ allowNameless,
22488
22490
 
22489
- const { value, hasStateProp, state } = controlInfo;
22490
- uiStateController.value = value;
22491
- if (hasStateProp) {
22492
- uiStateController.hasStateProp = true;
22493
- const currentState = uiStateController.state;
22494
- if (compareTwoJsValues(state, currentState)) ; else {
22495
- uiStateController.state = state;
22496
- uiStateController.setUIState(
22497
- state,
22498
- new CustomEvent("state_prop_change"),
22491
+ props,
22492
+ ref: props.ref,
22493
+ id: props.id,
22494
+ name: props.name,
22495
+
22496
+ state: stateInitial,
22497
+ uiState: stateInitial,
22498
+ uiStateSignal,
22499
+ value: controlInfo.value,
22500
+
22501
+ facadeChild: null,
22502
+ getManagedControls: () => {
22503
+ if (controller.facadeChild) {
22504
+ const child = controller.facadeChild;
22505
+ const childManaged = child.getManagedControls();
22506
+ if (childManaged.length > 0) return childManaged;
22507
+ return [child];
22508
+ }
22509
+ return [];
22510
+ },
22511
+ onUIAction: (e, { skipCommand } = {}) => {
22512
+ if (controlType === "button" && controller.controlHostProps.name) {
22513
+ const buttonName = controller.controlHostProps.name;
22514
+ const parentController = controller.parentUIStateController;
22515
+ if (parentController && parentController.wantRequesterButtonState) {
22516
+ const currentState = parentController.uiState;
22517
+ const mergedState = {
22518
+ ...currentState,
22519
+ [buttonName]: controller.uiState,
22520
+ };
22521
+ parentController.syncInternalState(mergedState);
22522
+ debugUIState(
22523
+ `merging button state into parent control group:`,
22524
+ mergedState,
22525
+ );
22526
+ }
22527
+ }
22528
+ // Trigger uiAction/command side effects without changing UI state.
22529
+ const currentUIState = controller.uiState;
22530
+ s.uiActionInternal?.(currentUIState, e);
22531
+ if (s.uiAction) {
22532
+ debugUIState(`calling uiAction for ${controlType}`, currentUIState);
22533
+ s.uiAction(currentUIState, e);
22534
+ }
22535
+ if (skipCommand) ; else {
22536
+ const command = controller.controlHostProps.command;
22537
+ if (command) {
22538
+ const element = controller.ref.current;
22539
+ if (element) {
22540
+ debugUIState(
22541
+ `triggering command "${command}" for "${controlType}"`,
22542
+ );
22543
+ triggerNaviCommand(element, command, e);
22544
+ }
22545
+ }
22546
+ }
22547
+ },
22548
+ setUIState: (newUIState, e) => {
22549
+ const guardResult = controller.rules.guard.checkUIState(
22550
+ newUIState,
22551
+ e,
22499
22552
  );
22500
- }
22501
- } else if (uiStateController.hasStateProp) {
22502
- uiStateController.hasStateProp = false;
22503
- uiStateController.state = stateInitial;
22504
- }
22505
- },
22506
-
22507
- controlType,
22508
- parentUIStateController,
22509
- isProxy,
22510
- allowNameless,
22511
- elementRef: ref,
22512
- props,
22513
-
22514
- id: props.id,
22515
- name: props.name,
22516
- state: stateInitial,
22517
- uiState: stateInitial,
22518
- uiStateSignal,
22519
- value: controlInfo.value,
22520
-
22521
- facadeChild: null,
22522
- getManagedControls: () => {
22523
- if (uiStateController.facadeChild) {
22524
- const child = uiStateController.facadeChild;
22525
- const childManaged = child.getManagedControls();
22526
- if (childManaged.length > 0) {
22527
- return childManaged;
22528
- }
22529
- return [child];
22530
- }
22531
- return [];
22532
- },
22533
- onUIAction: (e, { skipCommand } = {}) => {
22534
- if (controlType === "button" && uiStateController.controlHostProps.name) {
22535
- const buttonName = uiStateController.controlHostProps.name;
22536
- const parentController = uiStateController.parentUIStateController;
22537
- if (parentController && parentController.wantRequesterButtonState) {
22538
- const currentState = parentController.uiState;
22539
- const mergedState = {
22540
- ...currentState,
22541
- [buttonName]: uiStateController.uiState,
22542
- };
22543
- parentController.syncInternalState(mergedState);
22544
- debugUIState(
22545
- `merging button state into parent control group:`,
22546
- mergedState,
22553
+ if (guardResult) {
22554
+ if (Object.hasOwn(guardResult, "fixedValue")) {
22555
+ newUIState = guardResult.fixedValue;
22556
+ // fall through — continue with truncated value (callout already shown by guard)
22557
+ } else {
22558
+ return false;
22559
+ }
22560
+ }
22561
+ const controllerSig = getElementSignature(
22562
+ e.currentTarget || controller.ref.current,
22547
22563
  );
22548
- }
22549
- }
22550
- // Trigger uiAction/command side effects without changing UI state.
22551
- const currentUIState = uiStateController.uiState;
22552
- uiActionInternal?.(currentUIState, e);
22553
- if (uiAction) {
22554
- debugUIState(`calling uiAction for ${controlType}`, currentUIState);
22555
- uiAction(currentUIState, e);
22556
- }
22557
- if (skipCommand) ; else {
22558
- const command = uiStateController.controlHostProps.command;
22559
- if (command) {
22560
- const element = uiStateController.elementRef.current;
22561
- if (element) {
22564
+ const currentUIState = controller.uiState;
22565
+ const stateIsTheSame = compareTwoJsValues(newUIState, currentUIState);
22566
+ if (stateIsTheSame) {
22567
+ if (controlType === "button" || controlType === "link") {
22568
+ if (!isInternalEvent(e)) {
22569
+ controller.onUIAction(e);
22570
+ }
22571
+ return true;
22572
+ }
22562
22573
  debugUIState(
22563
- `triggering command "${command}" for "${controlType}"`,
22574
+ e,
22575
+ `${controllerSig}.setUIState(${JSON.stringify(newUIState)}, "${e.type}") -> state unchanged, no update needed`,
22564
22576
  );
22565
- triggerNaviCommand(element, command, e);
22577
+ if (
22578
+ controlType === "input" &&
22579
+ controller.controlHostProps.type === "radio" &&
22580
+ !isInternalEvent(e)
22581
+ ) {
22582
+ s.parentUIStateController?.onChildUIAction(controller, e, {
22583
+ stateChanged: false,
22584
+ });
22585
+ }
22586
+ if (e.currentTarget === null) {
22587
+ // A stale/reused event (currentTarget is null) means this is a debounced
22588
+ // callback firing the original input event after a timeout. The state hasn't
22589
+ // changed and this is not a live user gesture — skip uiAction and command.
22590
+ return false;
22591
+ }
22592
+ if (e.type === "state_prop_change") {
22593
+ // state_prop_change with the same uiState means the state prop was updated
22594
+ // to match what the user already has in the UI (e.g. action completed and
22595
+ // synced state back). No real user gesture — skip uiAction and command.
22596
+ return false;
22597
+ }
22598
+ if (e.type === "change") {
22599
+ // "change" fires after "input" for native inputs (date, color, etc.).
22600
+ // The "input" event already updated the state and fired uiAction.
22601
+ // When state is unchanged here it means "input" already ran — skip to
22602
+ // avoid a duplicate uiAction on the same user gesture.
22603
+ return false;
22604
+ }
22605
+ controller.onUIAction(e);
22606
+ return false;
22566
22607
  }
22567
- }
22568
- }
22569
- },
22570
- setUIState: (newUIState, e) => {
22571
- const guardResult = uiStateController.rules.guard.checkUIState(
22572
- newUIState,
22573
- e,
22574
- );
22575
- if (guardResult) {
22576
- if (Object.hasOwn(guardResult, "fixedValue")) {
22577
- newUIState = guardResult.fixedValue;
22578
- // fall through continue with truncated value (callout already shown by guard)
22579
- } else {
22580
- return false;
22581
- }
22582
- }
22583
- const controllerSig = getElementSignature(e.currentTarget || ref.current);
22584
- // if (persists) {
22585
- // setNavState(prop);
22586
- // }
22587
- const currentUIState = uiStateController.uiState;
22588
- const stateIsTheSame = compareTwoJsValues(newUIState, currentUIState);
22589
- if (stateIsTheSame) {
22590
- if (controlType === "button" || controlType === "link") {
22591
- if (!isInternalEvent(e)) {
22592
- uiStateController.onUIAction(e);
22608
+ // set immediatly (don't wait for preact re-render) so ui is in the right state for:
22609
+ // - side effect
22610
+ // - any "input" event that might be dispatched below
22611
+ syncDomState(newUIState, e);
22612
+ controller.uiState = newUIState;
22613
+ ownUIStateSignal.value = newUIState;
22614
+ const controlProxyFor =
22615
+ controller.controlHostProps["navi-control-proxy-for"];
22616
+ // Radio group: when a radio becomes checked, uncheck all siblings.
22617
+ // We only update their UIState — no parent notification, no synthetic
22618
+ // input event (the browser never fires input on the unchecked radios,
22619
+ // and we don't want to trigger their action flow with a stale DOM value).
22620
+ // Uses the in-memory registry instead of DOM queries so this works even
22621
+ // when sibling items are virtualized (not in the DOM).
22622
+ // Form scoping is preserved by comparing parentUIStateController references.
22623
+ if (isRadio && newUIState && controller.name && !controlProxyFor) {
22624
+ const siblings = getRadioSiblings(controller);
22625
+ if (siblings) {
22626
+ const siblingUncheckEvent = new CustomEvent(
22627
+ "radio_sibling_uncheck",
22628
+ { detail: {} },
22629
+ );
22630
+ chainEvent(siblingUncheckEvent, e);
22631
+ for (const siblingController of siblings) {
22632
+ if (siblingController === controller) continue;
22633
+ if (
22634
+ siblingController.parentUIStateController !==
22635
+ s.parentUIStateController
22636
+ ) {
22637
+ continue;
22638
+ }
22639
+ siblingController.setUIState(undefined, siblingUncheckEvent);
22640
+ }
22641
+ }
22593
22642
  }
22594
- return true;
22595
- }
22596
- debugUIState(
22597
- e,
22598
- `${controllerSig}.setUIState(${JSON.stringify(newUIState)}, "${e.type}") -> state unchanged, no update needed`,
22599
- );
22600
- if (
22601
- controlType === "input" &&
22602
- uiStateController.controlHostProps.type === "radio" &&
22603
- !isInternalEvent(e)
22604
- ) {
22605
- notifyParentAboutChildUIAction(e, { stateChanged: false });
22606
- }
22607
- // A stale/reused event (currentTarget is null) means this is a debounced
22608
- // callback firing the original input event after a timeout. The state hasn't
22609
- // changed and this is not a live user gesture — skip uiAction and command.
22610
- if (e.currentTarget === null) {
22611
- return false;
22612
- }
22613
- // state_prop_change with the same uiState means the state prop was updated
22614
- // to match what the user already has in the UI (e.g. action completed and
22615
- // synced state back). No real user gesture — skip uiAction and command.
22616
- if (e.type === "state_prop_change") {
22617
- return false;
22618
- }
22619
- // "change" fires after "input" for native inputs (date, color, etc.).
22620
- // The "input" event already updated the state and fired uiAction.
22621
- // When state is unchanged here it means "input" already ran — skip to
22622
- // avoid a duplicate uiAction on the same user gesture.
22623
- if (e.type === "change") {
22624
- return false;
22625
- }
22626
- uiStateController.onUIAction(e);
22627
- return false;
22628
- }
22629
- // set immediatly (don't wait for preact re-render) so ui is in the right state for:
22630
- // - side effect
22631
- // - any "input" event that might be dispatched below
22632
- syncDomState(newUIState, e);
22633
- uiStateController.uiState = newUIState;
22634
- ownUIStateSignal.value = newUIState;
22635
- // Radio group: when a radio becomes checked, uncheck all siblings.
22636
- // We only update their UIState — no parent notification, no synthetic
22637
- // input event (the browser never fires input on the unchecked radios,
22638
- // and we don't want to trigger their action flow with a stale DOM value).
22639
- // Uses the in-memory registry instead of DOM queries so this works even
22640
- // when sibling items are virtualized (not in the DOM).
22641
- // Form scoping is preserved by comparing parentUIStateController references.
22642
- const controlProxyFor =
22643
- uiStateController.controlHostProps["navi-control-proxy-for"];
22644
- if (isRadio && newUIState && uiStateController.name && !controlProxyFor) {
22645
- const siblings = getRadioSiblings(uiStateController);
22646
- if (siblings) {
22647
- const siblingUncheckEvent = new CustomEvent("radio_sibling_uncheck", {
22648
- detail: {},
22649
- });
22650
- chainEvent(siblingUncheckEvent, e);
22651
- for (const siblingController of siblings) {
22652
- if (siblingController === uiStateController) {
22653
- continue;
22643
+ debugUIState(e, `publishUIState(${JSON.stringify(newUIState)})`);
22644
+ publishUIState(newUIState, e);
22645
+ const el = controller.ref.current;
22646
+ // Always notify the element that its UI state changed.
22647
+ // Listeners use this to stay in sync (e.g. input_effect.js tracks currentState,
22648
+ // useUIState subscribes for reactive updates). Separate from navi_set_ui_state
22649
+ // which is the command; navi_ui_state_change is the notification.
22650
+ if (el) {
22651
+ dispatchInternalCustomEvent(el, "navi_ui_state_change", {
22652
+ event: e,
22653
+ value: newUIState,
22654
+ });
22655
+ }
22656
+ if (!controlProxyFor) {
22657
+ // When this controller is a real input that has a visible proxy
22658
+ // (linked via `navi-control-proxy-for`), mirror the new state to the
22659
+ // proxy DOM synchronously. Otherwise the proxy would only catch up
22660
+ // later through a React re-render — visible as e.g. two radios
22661
+ // appearing checked at once between the real input update and the
22662
+ // next render (radio_sibling_uncheck case).
22663
+ const proxyController = findProxyController(s.id);
22664
+ if (proxyController) {
22665
+ // Find any mounted controller that declared itself as a proxy for this one.
22666
+ // Communicates directly to the proxy controller — no DOM query needed.
22667
+ const mirrorEvent = new CustomEvent("proxy_mirror_state", {
22668
+ detail: {},
22669
+ });
22670
+ chainEvent(mirrorEvent, e);
22671
+ proxyController.setUIState(newUIState, mirrorEvent);
22672
+ }
22673
+ }
22674
+ if (isInternalEvent(e)) {
22675
+ if (e.type === "facade_child_mount_sync") {
22676
+ const wasEmptyString =
22677
+ currentUIState === "" && newUIState === undefined;
22678
+ const wasUndefinedNowEmpty =
22679
+ currentUIState === undefined && newUIState === "";
22680
+ if (wasEmptyString) {
22681
+ console.warn(
22682
+ `[navi] Picker mount sync changed state from "" to undefined. ` +
22683
+ `This will call uiAction on mount, which is likely unintended. ` +
22684
+ `Initialise the signal with undefined instead of "" to avoid this.`,
22685
+ );
22686
+ } else if (wasUndefinedNowEmpty) {
22687
+ console.warn(
22688
+ `[navi] Picker mount sync changed state from undefined to "". ` +
22689
+ `This will call uiAction on mount, which is likely unintended. ` +
22690
+ `The child component is emitting "" for an empty value — it should emit undefined instead.`,
22691
+ );
22692
+ }
22693
+ }
22694
+ // initial_state_push is pure initialization (equivalent to defaultValue on the
22695
+ // child itself): skip uiAction entirely so no side effects fire on mount.
22696
+ if (e.type !== "initial_state_push") {
22697
+ // Still fire uiAction so external listeners (e.g. signals) stay in
22698
+ // sync, but do NOT fire the command and do NOT notify the parent —
22699
+ // both would cause an infinite loop when a parent cascades state
22700
+ // down to its children (child command would re-trigger the cascade).
22701
+ controller.onUIAction(e, { skipCommand: true });
22702
+ }
22703
+ if (e.type === "facade_propagate_up") {
22704
+ // Exception: when the facade propagates a child state change up to the
22705
+ // real picker input, also notify the parent group (e.g. Form) so it
22706
+ // keeps its cached aggregated state in sync and fires its own uiAction.
22707
+ // This is consistent with how a direct Input inside a Form behaves:
22708
+ // the Form's uiAction fires on every value change.
22709
+ s.parentUIStateController?.onChildUIAction(controller, e, {
22710
+ stateChanged: true,
22711
+ });
22654
22712
  }
22655
22713
  if (
22656
- siblingController.parentUIStateController !==
22657
- parentUIStateController
22714
+ e.type === "state_prop_change" &&
22715
+ s.parentUIStateController &&
22716
+ !s.parentUIStateController.hasStateProp
22658
22717
  ) {
22659
- continue;
22718
+ // Exception: state_prop_change can only fire on a control with its own
22719
+ // controlled state/value prop (see hasStateProp above) — groups never
22720
+ // cascade state down into such children (they're explicitly skipped,
22721
+ // see shouldPropagateStateToChild/hasStateProp checks), so this change
22722
+ // can never be an echo of the parent's own cascade. The loop risk this
22723
+ // suppression exists for only applies when the parent itself just pushed
22724
+ // this value down, which requires the parent to be controlled (have its
22725
+ // own state/value prop). When the parent is "stateless" (uncontrolled),
22726
+ // notifying it is always safe and necessary — otherwise its aggregated
22727
+ // state silently drifts out of sync with this child.
22728
+ s.parentUIStateController.onChildUIAction(controller, e, {
22729
+ stateChanged: true,
22730
+ });
22660
22731
  }
22661
- siblingController.setUIState(undefined, siblingUncheckEvent);
22662
- }
22663
- }
22664
- }
22665
- debugUIState(e, `publishUIState(${JSON.stringify(newUIState)})`);
22666
- publishUIState(newUIState, e);
22667
- const el = ref.current;
22668
- // Always notify the element that its UI state changed.
22669
- // Listeners use this to stay in sync (e.g. input_effect.js tracks currentState,
22670
- // useUIState subscribes for reactive updates). Separate from navi_set_ui_state
22671
- // which is the command; navi_ui_state_change is the notification.
22672
- if (el) {
22673
- dispatchInternalCustomEvent(el, "navi_ui_state_change", {
22674
- event: e,
22675
- value: newUIState,
22676
- });
22677
- }
22678
- // When this controller is a real input that has a visible proxy
22679
- // (linked via `navi-control-proxy-for`), mirror the new state to the
22680
- // proxy DOM synchronously. Otherwise the proxy would only catch up
22681
- // later through a React re-render — visible as e.g. two radios
22682
- // appearing checked at once between the real input update and the
22683
- // next render (radio_sibling_uncheck case).
22684
- if (!controlProxyFor) {
22685
- // Find any mounted controller that declared itself as a proxy for this one.
22686
- // Communicates directly to the proxy controller — no DOM query needed.
22687
- const proxyController = findProxyController(id);
22688
- if (proxyController) {
22689
- const mirrorEvent = new CustomEvent("proxy_mirror_state", {
22690
- detail: {},
22691
- });
22692
- chainEvent(mirrorEvent, e);
22693
- proxyController.setUIState(newUIState, mirrorEvent);
22694
- }
22695
- }
22696
- if (isInternalEvent(e)) {
22697
- if (e.type === "facade_child_mount_sync") {
22698
- // Warn when the picker's initial signal value is "" but the list
22699
- // resolved to undefined (no selection). These are semantically
22700
- // equivalent but technically different, so the mount-sync registers
22701
- // as a state change and fires uiAction unexpectedly.
22702
- // Fix: initialise the signal with undefined instead of "".
22703
- const wasEmptyString =
22704
- currentUIState === "" && newUIState === undefined;
22705
- const wasUndefinedNowEmpty =
22706
- currentUIState === undefined && newUIState === "";
22707
- if (wasEmptyString) {
22708
- console.warn(
22709
- `[navi] Picker mount sync changed state from "" to undefined. ` +
22710
- `This will call uiAction on mount, which is likely unintended. ` +
22711
- `Initialise the signal with undefined instead of "" to avoid this.`,
22712
- );
22713
- } else if (wasUndefinedNowEmpty) {
22714
- console.warn(
22715
- `[navi] Picker mount sync changed state from undefined to "". ` +
22716
- `This will call uiAction on mount, which is likely unintended. ` +
22717
- `The child component is emitting "" for an empty value — it should emit undefined instead.`,
22718
- );
22732
+ return true;
22719
22733
  }
22720
- }
22721
- // initial_state_push is pure initialization (equivalent to defaultValue on the
22722
- // child itself): skip uiAction entirely so no side effects fire on mount.
22723
- if (e.type !== "initial_state_push") {
22724
- // Still fire uiAction so external listeners (e.g. signals) stay in
22725
- // sync, but do NOT fire the command and do NOT notify the parent —
22726
- // both would cause an infinite loop when a parent cascades state
22727
- // down to its children (child command would re-trigger the cascade).
22728
- uiStateController.onUIAction(e, {
22729
- skipCommand: true,
22730
- });
22731
- }
22732
- // Exception: when the facade propagates a child state change up to the
22733
- // real picker input, also notify the parent group (e.g. Form) so it
22734
- // keeps its cached aggregated state in sync and fires its own uiAction.
22735
- // This is consistent with how a direct Input inside a Form behaves:
22736
- // the Form's uiAction fires on every value change.
22737
- if (e.type === "facade_propagate_up") {
22738
- notifyParentAboutChildUIAction(e, { stateChanged: true });
22739
- }
22740
- // Exception: state_prop_change can only fire on a control with its own
22741
- // controlled state/value prop (see hasStateProp above) — groups never
22742
- // cascade state down into such children (they're explicitly skipped,
22743
- // see shouldPropagateStateToChild/hasStateProp checks), so this change
22744
- // can never be an echo of the parent's own cascade. The loop risk this
22745
- // suppression exists for only applies when the parent itself just pushed
22746
- // this value down, which requires the parent to be controlled (have its
22747
- // own state/value prop). When the parent is "stateless" (uncontrolled),
22748
- // notifying it is always safe and necessary — otherwise its aggregated
22749
- // state silently drifts out of sync with this child.
22750
- if (
22751
- e.type === "state_prop_change" &&
22752
- parentUIStateController &&
22753
- !parentUIStateController.hasStateProp
22754
- ) {
22755
- notifyParentAboutChildUIAction(e, { stateChanged: true });
22756
- }
22757
- return true;
22758
- }
22759
- notifyParentAboutChildUIAction(e, { stateChanged: true });
22760
- if (controlProxyFor) {
22761
- // Proxy: forward the state change to the real input.
22762
- // Use a dedicated internal event so that when the real input's setUIState
22763
- // sees stateIsTheSame=true (already updated by the real input's own flow),
22764
- // it does NOT fire notifyParentAboutChildUIAction(stateChanged=false) back
22765
- // to the group — which would trigger the group action with a stale value.
22766
- const targetController = getUIStateControllerById(controlProxyFor);
22767
- if (targetController) {
22768
- debugUIState(
22769
- e,
22770
- `forwarding set_ui_state "${newUIState}" to ${getElementSignature(targetController.elementRef.current)}`,
22771
- );
22772
- const forwardEvent = new CustomEvent("proxy_forward_set_ui_state", {
22773
- detail: {},
22734
+ s.parentUIStateController?.onChildUIAction(controller, e, {
22735
+ stateChanged: true,
22774
22736
  });
22775
- chainEvent(forwardEvent, e);
22776
- targetController.setUIState(newUIState, forwardEvent);
22777
- }
22778
- }
22779
- let syntheticInputFired = false;
22780
- if (el) {
22781
- // Dispatch a synthetic "input" event so external listeners see the new
22782
- // value. Skip when an input event on this element already exists in the chain.
22783
- const existingInputEvent = findEvent(e, (eInChain) => {
22784
- return eInChain.type === "input" && eInChain.target === el;
22785
- });
22786
- if (!existingInputEvent) {
22787
- if (el.tagName === "INPUT") {
22788
- if (el.type === "radio" || el.type === "checkbox") {
22737
+ if (controlProxyFor) {
22738
+ // Proxy: forward the state change to the real input.
22739
+ // Use a dedicated internal event so that when the real input's setUIState
22740
+ // sees stateIsTheSame=true (already updated by the real input's own flow),
22741
+ // it does NOT fire notifyParentAboutChildUIAction(stateChanged=false) back
22742
+ // to the group — which would trigger the group action with a stale value.
22743
+ const targetController = getUIStateControllerById(controlProxyFor);
22744
+ if (targetController) {
22789
22745
  debugUIState(
22790
22746
  e,
22791
- "dispatching synthetic input event without data for checkbox/radio",
22747
+ `forwarding set_ui_state "${newUIState}" to ${getElementSignature(targetController.ref.current)}`,
22792
22748
  );
22793
- el.dispatchEvent(new Event("input", { bubbles: true }));
22794
- syntheticInputFired = true;
22795
- } else {
22796
- debugUIState(
22797
- e,
22798
- `dispatching synthetic input event with data "${newUIState}" for input`,
22749
+ const forwardEvent = new CustomEvent(
22750
+ "proxy_forward_set_ui_state",
22751
+ { detail: {} },
22799
22752
  );
22800
- el.dispatchEvent(
22801
- new InputEvent("input", {
22802
- bubbles: true,
22803
- cancelable: true,
22804
- inputType: "insertText",
22805
- data: newUIState,
22806
- }),
22807
- );
22808
- syntheticInputFired = true;
22753
+ chainEvent(forwardEvent, e);
22754
+ targetController.setUIState(newUIState, forwardEvent);
22809
22755
  }
22810
22756
  }
22811
- // TODO: select, textarea
22812
- }
22813
- }
22814
- // When a synthetic "input" event was dispatched, the stateIsTheSame path
22815
- // already called onUIAction via the input event handler — skip here to
22816
- // avoid a duplicate uiAction on the same user gesture.
22817
- if (!syntheticInputFired) {
22818
- uiStateController.onUIAction(e);
22819
- }
22820
- // Sync validity after state change: re-check constraints against the new value.
22821
- // Internal events (programmatic) → silent check only.
22822
- // User events → full sync (may open/close callout).
22823
- if (isInternalEvent(e)) {
22824
- uiStateController.rules.validation.checkValidity({ event: e });
22825
- } else {
22826
- uiStateController.rules.validation.syncValidity(e);
22827
- }
22828
- return true;
22829
- },
22830
- clearUIState: (e) => {
22831
- // Radio and checkbox "unchecked" state is `undefined`, not `""`.
22832
- // Passing `""` would set checked=true because `"" !== undefined`.
22833
- const isCheckable =
22834
- controlType === "input" &&
22835
- (props.type === "radio" || props.type === "checkbox");
22836
- uiStateController.setUIState(isCheckable ? undefined : "", e);
22837
- },
22838
- resetUIState: (e) => {
22839
- uiStateController.setUIState(uiStateController.state, e);
22840
- },
22841
- onActionEnd: async (e) => {
22842
- debugUIState(`"${controlType}" actionEnd called`);
22843
- // wait for preact to re-render to update readonly as action end side effects are runned
22844
- // await new Promise((r) => requestAnimationFrame(r));
22845
- uiStateController.rules.validation.syncValidity(e);
22846
- },
22847
- onActionError: (e) => {
22848
- debugUIState(`"${controlType}" actionError called`);
22849
- uiStateController.rules.validation.syncValidity(e, { report: true });
22850
- },
22851
- subscribe: subscribeUIState,
22852
- // Leaf controls don't aggregate children, but they act as a transparent
22853
- // pass-through so that controls nested inside them (e.g. an Input inside
22854
- // a List.Item) can bubble up registration to the nearest group ancestor.
22855
- registerChild: (childUIStateController, options) => {
22856
- if (parentUIStateController) {
22857
- parentUIStateController.registerChild(childUIStateController, options);
22858
- }
22859
- },
22860
- unregisterChild: (childUIStateController) => {
22861
- if (parentUIStateController) {
22862
- parentUIStateController.unregisterChild(childUIStateController);
22863
- }
22757
+ // Dispatch a synthetic "input" event so external listeners see the new
22758
+ // value. Skip when an input event on this element already exists in the chain.
22759
+ let syntheticInputFired = false;
22760
+ if (el) {
22761
+ const existingInputEvent = findEvent(e, (eInChain) => {
22762
+ return eInChain.type === "input" && eInChain.target === el;
22763
+ });
22764
+ if (!existingInputEvent) {
22765
+ if (el.tagName === "INPUT") {
22766
+ if (el.type === "radio" || el.type === "checkbox") {
22767
+ debugUIState(
22768
+ e,
22769
+ "dispatching synthetic input event without data for checkbox/radio",
22770
+ );
22771
+ el.dispatchEvent(new Event("input", { bubbles: true }));
22772
+ syntheticInputFired = true;
22773
+ } else {
22774
+ debugUIState(
22775
+ e,
22776
+ `dispatching synthetic input event with data "${newUIState}" for input`,
22777
+ );
22778
+ el.dispatchEvent(
22779
+ new InputEvent("input", {
22780
+ bubbles: true,
22781
+ cancelable: true,
22782
+ inputType: "insertText",
22783
+ data: newUIState,
22784
+ }),
22785
+ );
22786
+ syntheticInputFired = true;
22787
+ }
22788
+ }
22789
+ // TODO: select, textarea
22790
+ }
22791
+ }
22792
+ if (!syntheticInputFired) {
22793
+ // When a synthetic "input" event was dispatched, the stateIsTheSame path
22794
+ // already called onUIAction via the input event handler — skip here to
22795
+ // avoid a duplicate uiAction on the same user gesture.
22796
+ controller.onUIAction(e);
22797
+ }
22798
+ // Sync validity after state change: re-check constraints against the new value.
22799
+ // Internal events (programmatic) silent check only.
22800
+ // User events full sync (may open/close callout).
22801
+ if (isInternalEvent(e)) {
22802
+ controller.rules.validation.checkValidity({ event: e });
22803
+ } else {
22804
+ controller.rules.validation.syncValidity(e);
22805
+ }
22806
+ return true;
22807
+ },
22808
+ clearUIState: (e) => {
22809
+ // Radio and checkbox "unchecked" state is `undefined`, not `""`.
22810
+ // Passing `""` would set checked=true because `"" !== undefined`.
22811
+ const isCheckable =
22812
+ controlType === "input" &&
22813
+ (props.type === "radio" || props.type === "checkbox");
22814
+ controller.setUIState(isCheckable ? undefined : "", e);
22815
+ },
22816
+ resetUIState: (e) => {
22817
+ controller.setUIState(controller.state, e);
22818
+ },
22819
+ onActionEnd: async (e) => {
22820
+ debugUIState(`"${controlType}" actionEnd called`);
22821
+ // wait for preact to re-render to update readonly as action end side effects are runned
22822
+ // await new Promise((r) => requestAnimationFrame(r));
22823
+ controller.rules.validation.syncValidity(e);
22824
+ },
22825
+ onActionError: (e) => {
22826
+ debugUIState(`"${controlType}" actionError called`);
22827
+ controller.rules.validation.syncValidity(e, { report: true });
22828
+ },
22829
+ subscribe: subscribeUIState,
22830
+ // Leaf controls act as a transparent pass-through so that controls
22831
+ // nested inside them (e.g. an Input inside a List.Item) can bubble
22832
+ // up registration to the nearest group ancestor.
22833
+ registerChild: (childUIStateController, options) => {
22834
+ s.parentUIStateController?.registerChild(
22835
+ childUIStateController,
22836
+ options,
22837
+ );
22838
+ },
22839
+ unregisterChild: (childUIStateController) => {
22840
+ s.parentUIStateController?.unregisterChild(childUIStateController);
22841
+ },
22842
+ onChildUIAction: (childUIStateController, e, options) => {
22843
+ s.parentUIStateController?.onChildUIAction(
22844
+ childUIStateController,
22845
+ e,
22846
+ options,
22847
+ );
22848
+ },
22849
+ };
22850
+ const rules = createControlRules(controller, {
22851
+ debugPopup,
22852
+ debugInteraction,
22853
+ debugUIState,
22854
+ debugFocus,
22855
+ });
22856
+ controller.rules = rules;
22857
+
22858
+ // Include all values that controller methods read from the scope so they
22859
+ // are available immediately — even if no re-render happens before the
22860
+ // first user interaction (update only runs on re-renders, not on mount).
22861
+ return {
22862
+ controller,
22863
+ ref: props.ref,
22864
+ id: props.id,
22865
+ name: props.name,
22866
+ props,
22867
+ controlInfo,
22868
+ uiAction: props.uiAction,
22869
+ uiActionInternal,
22870
+ parentUIStateController,
22871
+ parentUiStateSignalHolder,
22872
+ };
22864
22873
  },
22865
- onChildUIAction: (childUIStateController, e, options) => {
22866
- if (parentUIStateController) {
22867
- parentUIStateController.onChildUIAction(
22868
- childUIStateController,
22869
- e,
22870
- options,
22871
- );
22874
+ // ── update: runs every render after the first ─────────────────────────
22875
+ // Syncs public-facing fields and handles controlled state prop changes.
22876
+ (s) => {
22877
+ const { controller } = s;
22878
+ // Raw Preact props from the current render. These are the component's input props,
22879
+ // not the resolved/curated host props. useInteractiveProps overwrites
22880
+ // uiStateController.controlHostProps with the resolved subset on every render.
22881
+ controller.props = props;
22882
+ // Re-sync to this render's ref object. It's normally stable, but it can
22883
+ // legitimately change identity (e.g. switching from an internal fallback
22884
+ // ref to a forwarded one, or across an interrupted/resumed render such as
22885
+ // a Suspense boundary resolving) — if we kept the original ref forever,
22886
+ // `ref.current` would be stuck at whatever it was at creation time, even
22887
+ // after the controller has moved on to a different, live DOM node.
22888
+ controller.ref = props.ref;
22889
+ controller.id = props.id; // never supposed to change, not supported for now
22890
+ controller.name = props.name;
22891
+ controller.parentUIStateController = parentUIStateController;
22892
+ const { value, hasStateProp, state, stateInitial } = controlInfo;
22893
+ controller.value = value;
22894
+ if (hasStateProp) {
22895
+ controller.hasStateProp = true;
22896
+ const currentState = controller.state;
22897
+ if (!compareTwoJsValues(state, currentState)) {
22898
+ controller.state = state;
22899
+ controller.setUIState(state, new CustomEvent("state_prop_change"));
22900
+ }
22901
+ } else if (controller.hasStateProp) {
22902
+ controller.hasStateProp = false;
22903
+ controller.state = stateInitial;
22872
22904
  }
22905
+ return {
22906
+ ref: props.ref,
22907
+ id: props.id,
22908
+ name: props.name,
22909
+ props,
22910
+ controlInfo,
22911
+ uiAction: props.uiAction,
22912
+ uiActionInternal,
22913
+ parentUIStateController,
22914
+ };
22873
22915
  },
22874
- };
22875
- uiStateControllerRef.current = uiStateController;
22876
- const rules = createControlRules(uiStateController, {
22877
- debugPopup,
22878
- debugInteraction,
22879
- debugUIState,
22880
- debugFocus,
22881
- });
22882
- uiStateController.rules = rules;
22883
- return uiStateController;
22884
- };
22885
- const NO_PARENT = [() => {}, () => {}, () => {}];
22886
- const useParentControllerNotifiers = (
22887
- parentUIStateController,
22888
- uiStateControllerRef,
22889
- controlType,
22890
- debugUIState,
22891
- ) => {
22892
- return useMemo(() => {
22893
- if (!parentUIStateController) {
22894
- return NO_PARENT;
22895
- }
22916
+ );
22917
+ scope.parentUiStateSignalHolder.value =
22918
+ parentUIStateController?.uiStateSignal ?? null;
22896
22919
 
22897
- const parentControlType = parentUIStateController.controlType;
22898
- const notifyParentAboutChildMount = () => {
22899
- const uiStateController = uiStateControllerRef.current;
22900
- debugUIState(`"${controlType}" registering into "${parentControlType}"`);
22901
- parentUIStateController.registerChild(uiStateController);
22920
+ const { controller } = scope;
22921
+ const controllerRef = controller.ref;
22922
+ useLayoutEffect(() => {
22923
+ const el = controllerRef.current;
22924
+ if (el) {
22925
+ el.__uiStateController__ = controller;
22926
+ }
22927
+ return () => {
22928
+ if (el && el.__uiStateController__ === controller) {
22929
+ delete el.__uiStateController__;
22930
+ }
22931
+ onUIStateControllerDestroyed(controller);
22902
22932
  };
22933
+ }, [controllerRef]);
22903
22934
 
22904
- const notifyParentAboutChildUIAction = (
22905
- e,
22906
- { stateChanged, silent = false },
22907
- ) => {
22908
- const uiStateController = uiStateControllerRef.current;
22909
- debugUIState(
22910
- `"${controlType}" notifying "${parentControlType}" of child ui action (stateChanged: ${stateChanged})`,
22911
- );
22912
- parentUIStateController.onChildUIAction(uiStateController, e, {
22913
- stateChanged,
22914
- silent,
22915
- });
22916
- };
22935
+ const { parentUIStateController: parentController } = scope;
22936
+ useLayoutEffect(() => {
22937
+ if (!parentController) {
22938
+ return undefined;
22939
+ }
22917
22940
 
22918
- const notifyParentAboutChildUnmount = () => {
22919
- const uiStateController = uiStateControllerRef.current;
22941
+ debugUIState(`"${controlType}" registering into "${parent.controlType}"`);
22942
+ parentController.registerChild(controller);
22943
+ return () => {
22920
22944
  debugUIState(
22921
- `"${controlType}" unregistering from "${parentControlType}"`,
22945
+ `"${controlType}" unregistering from "${parent.controlType}"`,
22922
22946
  );
22923
- parentUIStateController.unregisterChild(uiStateController);
22947
+ parentController.unregisterChild(controller);
22924
22948
  };
22949
+ }, [parentController]);
22925
22950
 
22926
- return [
22927
- notifyParentAboutChildMount,
22928
- notifyParentAboutChildUIAction,
22929
- notifyParentAboutChildUnmount,
22930
- ];
22931
- }, []);
22951
+ return controller;
22932
22952
  };
22933
22953
 
22934
22954
  /**
@@ -23083,9 +23103,8 @@ const useUIGroupStateController = (
23083
23103
  const parentUIStateController = useContext(ParentUIStateControllerContext);
23084
23104
  const hasValueProp = Object.hasOwn(props, "value");
23085
23105
  const hasDefaultValueProp = Object.hasOwn(props, "defaultValue");
23086
- const { id, name, value, defaultValue, uiAction, command } = props;
23106
+ const { id, name, value, defaultValue, uiAction } = props;
23087
23107
  const ref = props.ref;
23088
- const uiActionRef = useRef(uiAction);
23089
23108
  const fallbackState =
23090
23109
  stateType === "array"
23091
23110
  ? EMPTY_ARRAY
@@ -23094,9 +23113,7 @@ const useUIGroupStateController = (
23094
23113
  : undefined;
23095
23114
  const childUIStateControllerArrayRef = useRef([]);
23096
23115
  const childUIStateControllerArray = childUIStateControllerArrayRef.current;
23097
- const controllerRef = useRef();
23098
- // Tracks children this controller rejected and delegated upward (bubble-up
23099
- // registration). Used to forward onChildUIAction and unregisterChild.
23116
+ // Tracks children rejected by the filter and delegated upward (bubble-up).
23100
23117
  const delegatedChildrenRef = useRef(new Map());
23101
23118
 
23102
23119
  const groupIsRenderingRef = useRef(false);
@@ -23104,103 +23121,8 @@ const useUIGroupStateController = (
23104
23121
  groupIsRenderingRef.current = true;
23105
23122
  pendingChangeRef.current = false;
23106
23123
 
23107
- const [
23108
- notifyParentAboutChildMount,
23109
- notifyParentAboutChildUIAction,
23110
- notifyParentAboutChildUnmount,
23111
- ] = useParentControllerNotifiers(
23112
- parentUIStateController,
23113
- controllerRef,
23114
- controlType,
23115
- debugUIGroup,
23116
- );
23117
- useLayoutEffect(() => {
23118
- const controller = controllerRef.current;
23119
- const el = ref.current;
23120
- if (el) {
23121
- el.__uiStateController__ = controller;
23122
- }
23123
- notifyParentAboutChildMount();
23124
- return () => {
23125
- notifyParentAboutChildUnmount();
23126
- onUIStateControllerDestroyed(controller);
23127
- };
23128
- }, []);
23129
-
23130
- const onChange = (e, { notifyExternal }) => {
23131
- if (groupIsRenderingRef.current) {
23132
- pendingChangeRef.current = true;
23133
- return;
23134
- }
23135
- const aggChildState = resolvedAggregateChildStates(
23136
- childUIStateControllerArray,
23137
- fallbackState,
23138
- );
23139
- const groupUIState =
23140
- aggChildState === undefined ? fallbackState : aggChildState;
23141
- debugUIGroup(
23142
- e,
23143
- `${controlType}.getUIState -> ${JSON.stringify(groupUIState)}`,
23144
- );
23145
- const groupUIStateController = controllerRef.current;
23146
- if (notifyExternal === true) {
23147
- applyState(groupUIState, e);
23148
- } else if (notifyExternal === "silent") {
23149
- // Silent mount/unmount sync: update state without triggering uiAction/command,
23150
- // but still notify parent (e.g. facade) so it can track the current child state.
23151
- groupUIStateController.syncInternalState(groupUIState, e);
23152
- notifyParentAboutChildUIAction(e, {
23153
- stateChanged: true,
23154
- silent: true,
23155
- });
23156
- } else {
23157
- groupUIStateController.syncInternalState(groupUIState, e);
23158
- }
23159
- };
23160
-
23161
- // Applies the aggregated state: updates signal, fires uiAction/command/navi_ui_state_change,
23162
- // and notifies the parent. Called both from onChange (after child UI action) and from
23163
- // setUIState (after cascading to children).
23164
- const applyState = (newUIState, e, { internalBehavior = false } = {}) => {
23165
- const groupUIStateController = controllerRef.current;
23166
- const currentUIState = groupUIStateController.uiState;
23167
- groupUIStateController.uiState = newUIState;
23168
- uiStateSignal.value = newUIState;
23169
- debugUIGroup(
23170
- e,
23171
- `${controlType}.applyState(${JSON.stringify(newUIState)}, "${e.type}") -> updates from ${JSON.stringify(currentUIState)} to ${JSON.stringify(newUIState)}`,
23172
- );
23173
- publishUIState(newUIState);
23174
- // Notify the parent (facade) BEFORE firing the command so that when a
23175
- // command like --navi-send closes the picker, the picker input already
23176
- // holds the new value.
23177
- notifyParentAboutChildUIAction(e, { stateChanged: true });
23178
- groupUIStateController.onUIAction(e, {
23179
- skipCommand: internalBehavior,
23180
- });
23181
- const el = ref.current;
23182
- if (el) {
23183
- dispatchInternalCustomEvent(el, "navi_ui_state_change", {
23184
- event: e,
23185
- value: newUIState,
23186
- });
23187
- }
23188
- };
23189
-
23190
- useLayoutEffect(() => {
23191
- groupIsRenderingRef.current = false;
23192
- if (pendingChangeRef.current) {
23193
- pendingChangeRef.current = false;
23194
- onChange(new CustomEvent(`${controlType}_batched_ui_state_update`), {
23195
- notifyExternal: "silent",
23196
- });
23197
- }
23198
- });
23199
-
23200
23124
  const isMonitoringChild = (childUIStateController) => {
23201
- if (childUIStateController.isProxy) {
23202
- return false;
23203
- }
23125
+ if (childUIStateController.isProxy) return false;
23204
23126
  if (
23205
23127
  resolvedChildControlFilter &&
23206
23128
  !resolvedChildControlFilter(childUIStateController)
@@ -23210,350 +23132,411 @@ const useUIGroupStateController = (
23210
23132
  return true;
23211
23133
  };
23212
23134
  const shouldPropagateStateToChild = (childUIStateController) => {
23213
- if (!isMonitoringChild(childUIStateController)) {
23214
- return false;
23215
- }
23216
- if (childUIStateController.controlType === "button") {
23217
- return false;
23218
- }
23219
- if (childUIStateController.controlType === "link") {
23220
- return false;
23221
- }
23135
+ if (!isMonitoringChild(childUIStateController)) return false;
23136
+ if (childUIStateController.controlType === "button") return false;
23137
+ if (childUIStateController.controlType === "link") return false;
23222
23138
  return true;
23223
23139
  };
23224
23140
 
23225
- const existingController = controllerRef.current;
23226
- if (existingController) {
23227
- const prevValue = existingController.value;
23228
- const prevHasValueProp = existingController.hasValueProp;
23229
- existingController.props = props;
23230
- existingController.id = id;
23231
- existingController.name = name;
23232
- existingController.value = value;
23233
- existingController.defaultValue = defaultValue;
23234
- existingController.hasValueProp = hasValueProp;
23235
- existingController.hasDefaultValueProp = hasDefaultValueProp;
23236
- uiActionRef.current = uiAction;
23237
- // When the controlled value prop changes (or when becoming controlled for the
23238
- // first time), silently cascade to children that have no individual state prop.
23239
- if (
23240
- hasValueProp &&
23241
- (!prevHasValueProp || !compareTwoJsValues(value, prevValue))
23242
- ) {
23243
- const propagateDownEvent = new CustomEvent(
23244
- "propagate_down_set_ui_state",
23245
- { detail: {} },
23141
+ const scope = useRenderScope(
23142
+ // ── init: runs once on mount ───────────────────────────────────────────
23143
+ (s) => {
23144
+ debugUIGroup(
23145
+ `Creating "${controlType}" ui state controller (monitoring some descendants ui state(s))"`,
23246
23146
  );
23247
- for (const childUIStateController of childUIStateControllerArray) {
23248
- if (!shouldPropagateStateToChild(childUIStateController)) {
23249
- continue;
23250
- }
23251
- if (childUIStateController.hasStateProp) {
23252
- continue;
23147
+ const [publishUIState, subscribeUIState] = createPubSub();
23148
+ const uiStateSignal = signal(fallbackState);
23149
+
23150
+ // onChange and applyState live inside init so they close over the stable
23151
+ // signals/pubsub without needing external refs.
23152
+ const onChange = (e, { notifyExternal }) => {
23153
+ if (groupIsRenderingRef.current) {
23154
+ pendingChangeRef.current = true;
23155
+ return;
23253
23156
  }
23254
- const childNewState = existingController.distributeChildUIState(
23255
- value,
23256
- childUIStateController,
23157
+ const aggChildState = resolvedAggregateChildStates(
23158
+ childUIStateControllerArray,
23159
+ fallbackState,
23257
23160
  );
23258
- if (childNewState === CANNOT_DERIVE) {
23259
- continue;
23260
- }
23261
- childUIStateController.setUIState(childNewState, propagateDownEvent);
23262
- }
23263
- existingController.syncInternalState(value);
23264
- }
23265
- return existingController;
23266
- }
23267
- debugUIGroup(
23268
- `Creating "${controlType}" ui state controller (monitoring some descendants ui state(s))"`,
23269
- );
23270
-
23271
- const [publishUIState, subscribeUIState] = createPubSub();
23272
- const uiStateSignal = signal(fallbackState);
23273
- const groupUIStateController = {
23274
- controlType,
23275
- id,
23276
- name,
23277
- value,
23278
- defaultValue,
23279
- hasValueProp,
23280
- hasDefaultValueProp,
23281
- props,
23282
- uiState: fallbackState,
23283
- uiStateSignal,
23284
- wantRequesterButtonState,
23285
- elementRef: ref,
23286
- getPropFromState: (uiState) => uiState,
23287
- distributeChildUIState: resolvedDistributeChildUIState,
23288
- // Cascades newUIState to each monitored child via resolvedDistributeChildUIState,
23289
- // then re-aggregates and fires this group's own reactions.
23290
- setUIState: (newUIState, e) => {
23291
- if (
23292
- stateType === "object" &&
23293
- (newUIState === null || typeof newUIState !== "object")
23294
- ) {
23295
- console.warn(
23296
- `[${controlType}] setUIState received a non-object value: ${JSON.stringify(newUIState)} (expected an object). Ignoring.`,
23297
- newUIState,
23298
- );
23299
- return;
23300
- }
23301
- if (stateType === "array" && !Array.isArray(newUIState)) {
23302
- console.warn(
23303
- `[${controlType}] setUIState received a non-array value: ${JSON.stringify(newUIState)} (expected an array). Ignoring.`,
23304
- newUIState,
23161
+ const groupUIState =
23162
+ aggChildState === undefined ? fallbackState : aggChildState;
23163
+ debugUIGroup(
23164
+ e,
23165
+ `${controlType}.getUIState -> ${JSON.stringify(groupUIState)}`,
23305
23166
  );
23306
- return;
23307
- }
23308
- // initial_state_push propagates silently (no uiAction anywhere in the chain);
23309
- // regular updates use propagate_down_set_ui_state which fires uiAction on children.
23310
- const propagateEventType =
23311
- e.type === "initial_state_push"
23312
- ? "initial_state_push"
23313
- : "propagate_down_set_ui_state";
23314
- const propagateDownEvent = new CustomEvent(propagateEventType, {
23315
- detail: {},
23316
- });
23317
- chainEvent(propagateDownEvent, e);
23318
- for (const childUIStateController of childUIStateControllerArray) {
23319
- if (!shouldPropagateStateToChild(childUIStateController)) {
23320
- continue;
23167
+ const { controller } = s;
23168
+ if (notifyExternal === true) {
23169
+ applyState(groupUIState, e);
23170
+ } else if (notifyExternal === "silent") {
23171
+ controller.syncInternalState(groupUIState, e);
23172
+ s.parentUIStateController?.onChildUIAction(controller, e, {
23173
+ stateChanged: true,
23174
+ silent: true,
23175
+ });
23176
+ } else {
23177
+ controller.syncInternalState(groupUIState, e);
23321
23178
  }
23322
- const childNewState = resolvedDistributeChildUIState(
23323
- newUIState,
23324
- childUIStateController,
23179
+ };
23180
+
23181
+ const applyState = (newUIState, e, { internalBehavior = false } = {}) => {
23182
+ const { controller } = s;
23183
+ const currentUIState = controller.uiState;
23184
+ controller.uiState = newUIState;
23185
+ uiStateSignal.value = newUIState;
23186
+ debugUIGroup(
23187
+ e,
23188
+ `${controlType}.applyState(${JSON.stringify(newUIState)}, "${e.type}") -> updates from ${JSON.stringify(currentUIState)} to ${JSON.stringify(newUIState)}`,
23325
23189
  );
23326
- if (childNewState === CANNOT_DERIVE) {
23327
- continue;
23328
- }
23329
- childUIStateController.setUIState(childNewState, propagateDownEvent);
23330
- }
23331
- // Re-aggregate from children and apply — do NOT call onChange to avoid a loop
23332
- // (onChange would call setUIState again, which would cascade again).
23333
- const aggChildState = resolvedAggregateChildStates(
23334
- childUIStateControllerArray,
23335
- fallbackState,
23336
- );
23337
- const groupUIState =
23338
- aggChildState === undefined ? fallbackState : aggChildState;
23339
- if (e.type === "initial_state_push") {
23340
- // Silent initialization: update state without firing uiAction or notifying parent.
23341
- groupUIStateController.syncInternalState(groupUIState);
23342
- return;
23343
- }
23344
- applyState(groupUIState, e, { internalBehavior: true });
23345
- },
23346
- // Called on mount/unmount/render-batch: updates state silently with no external reactions.
23347
- syncInternalState: (newUIState) => {
23348
- const currentUIState = groupUIStateController.uiState;
23349
- if (newUIState === currentUIState) {
23350
- return;
23351
- }
23352
- groupUIStateController.uiState = newUIState;
23353
- uiStateSignal.value = newUIState;
23354
- publishUIState(newUIState);
23355
- },
23356
- // Called when a child UI action does NOT change the aggregated value (e.g. radio re-clicked).
23357
- // Fires uiAction + command without touching state or the action pipeline.
23358
- onUIAction: (e, { skipCommand } = {}) => {
23359
- const currentUIState = groupUIStateController.uiState;
23360
- const uiAction = uiActionRef.current;
23361
- uiAction?.(currentUIState, e);
23362
- uiActionInternal?.(currentUIState, e);
23363
- if (skipCommand) ; else if (command) {
23364
- const el = ref.current;
23190
+ publishUIState(newUIState);
23191
+ s.parentUIStateController?.onChildUIAction(controller, e, {
23192
+ stateChanged: true,
23193
+ });
23194
+ controller.onUIAction(e, { skipCommand: internalBehavior });
23195
+ const el = controller.ref.current;
23365
23196
  if (el) {
23366
- triggerNaviCommand(el, command, e);
23197
+ dispatchInternalCustomEvent(el, "navi_ui_state_change", {
23198
+ event: e,
23199
+ value: newUIState,
23200
+ });
23367
23201
  }
23368
- }
23369
- },
23370
- registerChild: (
23371
- childUIStateController,
23372
- // { bubbled = false } = {}
23373
- ) => {
23374
- if (!isMonitoringChild(childUIStateController)) {
23375
- // Filter rejected this child.
23376
- if (!allowCapture && parentUIStateController) {
23377
- // Not a boundary — bubble the child up to the nearest ancestor.
23378
- delegatedChildrenRef.current.set(
23202
+ };
23203
+
23204
+ const controller = {
23205
+ controlType,
23206
+ id,
23207
+ name,
23208
+ value,
23209
+ defaultValue,
23210
+ hasValueProp,
23211
+ hasDefaultValueProp,
23212
+ props,
23213
+ uiState: fallbackState,
23214
+ uiStateSignal,
23215
+ wantRequesterButtonState,
23216
+ ref,
23217
+ getPropFromState: (uiState) => uiState,
23218
+ distributeChildUIState: resolvedDistributeChildUIState,
23219
+ setUIState: (newUIState, e) => {
23220
+ if (
23221
+ stateType === "object" &&
23222
+ (newUIState === null || typeof newUIState !== "object")
23223
+ ) {
23224
+ console.warn(
23225
+ `[${controlType}] setUIState received a non-object value: ${JSON.stringify(newUIState)} (expected an object). Ignoring.`,
23226
+ newUIState,
23227
+ );
23228
+ return;
23229
+ }
23230
+ if (stateType === "array" && !Array.isArray(newUIState)) {
23231
+ console.warn(
23232
+ `[${controlType}] setUIState received a non-array value: ${JSON.stringify(newUIState)} (expected an array). Ignoring.`,
23233
+ newUIState,
23234
+ );
23235
+ return;
23236
+ }
23237
+ const propagateEventType =
23238
+ e.type === "initial_state_push"
23239
+ ? "initial_state_push"
23240
+ : "propagate_down_set_ui_state";
23241
+ const propagateDownEvent = new CustomEvent(propagateEventType, {
23242
+ detail: {},
23243
+ });
23244
+ chainEvent(propagateDownEvent, e);
23245
+ for (const childUIStateController of childUIStateControllerArray) {
23246
+ if (!shouldPropagateStateToChild(childUIStateController)) continue;
23247
+ const childNewState = resolvedDistributeChildUIState(
23248
+ newUIState,
23249
+ childUIStateController,
23250
+ );
23251
+ if (childNewState === CANNOT_DERIVE) continue;
23252
+ childUIStateController.setUIState(
23253
+ childNewState,
23254
+ propagateDownEvent,
23255
+ );
23256
+ }
23257
+ const aggChildState = resolvedAggregateChildStates(
23258
+ childUIStateControllerArray,
23259
+ fallbackState,
23260
+ );
23261
+ const groupUIState =
23262
+ aggChildState === undefined ? fallbackState : aggChildState;
23263
+ if (e.type === "initial_state_push") {
23264
+ controller.syncInternalState(groupUIState);
23265
+ return;
23266
+ }
23267
+ applyState(groupUIState, e, { internalBehavior: true });
23268
+ },
23269
+ syncInternalState: (newUIState) => {
23270
+ const currentUIState = controller.uiState;
23271
+ if (newUIState === currentUIState) return;
23272
+ controller.uiState = newUIState;
23273
+ uiStateSignal.value = newUIState;
23274
+ publishUIState(newUIState);
23275
+ },
23276
+ onUIAction: (e, { skipCommand } = {}) => {
23277
+ const currentUIState = controller.uiState;
23278
+ s.uiAction?.(currentUIState, e);
23279
+ s.uiActionInternal?.(currentUIState, e);
23280
+ if (!skipCommand && controller.props.command) {
23281
+ const el = controller.ref.current;
23282
+ if (el) triggerNaviCommand(el, controller.props.command, e);
23283
+ }
23284
+ },
23285
+ registerChild: (childUIStateController) => {
23286
+ if (!isMonitoringChild(childUIStateController)) {
23287
+ const currentParent = s.parentUIStateController;
23288
+ if (!allowCapture && currentParent) {
23289
+ delegatedChildrenRef.current.set(
23290
+ childUIStateController,
23291
+ currentParent,
23292
+ );
23293
+ currentParent.registerChild(childUIStateController);
23294
+ }
23295
+ return;
23296
+ }
23297
+ const childControlType = childUIStateController.controlType;
23298
+ childUIStateControllerArray.push(childUIStateController);
23299
+ debugUIGroup(
23300
+ `${controlType}.registerChild("${childControlType}") -> registered (total: ${childUIStateControllerArray.length})`,
23301
+ );
23302
+ if (!childUIStateController.hasStateProp) {
23303
+ const initialEvent = new CustomEvent("initial_state_push", {
23304
+ detail: {},
23305
+ });
23306
+ if (controller.hasValueProp) {
23307
+ const childNewState = resolvedDistributeChildUIState(
23308
+ controller.value,
23309
+ childUIStateController,
23310
+ );
23311
+ if (childNewState !== CANNOT_DERIVE) {
23312
+ childUIStateController.setUIState(childNewState, initialEvent);
23313
+ }
23314
+ } else if (controller.hasDefaultValueProp) {
23315
+ const childNewState = resolvedDistributeChildUIState(
23316
+ controller.defaultValue,
23317
+ childUIStateController,
23318
+ );
23319
+ if (childNewState !== CANNOT_DERIVE) {
23320
+ childUIStateController.setUIState(childNewState, initialEvent);
23321
+ }
23322
+ }
23323
+ }
23324
+ onChange(new CustomEvent(`${childControlType}_mount`), {
23325
+ notifyExternal: "silent",
23326
+ });
23327
+ },
23328
+ onChildUIAction: (
23329
+ childUIStateController,
23330
+ e,
23331
+ { stateChanged, silent },
23332
+ ) => {
23333
+ const delegatedTo = delegatedChildrenRef.current.get(
23379
23334
  childUIStateController,
23380
- parentUIStateController,
23381
23335
  );
23382
- parentUIStateController.registerChild(childUIStateController);
23383
- }
23384
- // allowCapture=true: hard boundary, stop bubbling and drop silently.
23385
- // No parent: end of chain, drop silently.
23386
- return;
23387
- }
23388
- const childControlType = childUIStateController.controlType;
23389
- childUIStateControllerArray.push(childUIStateController);
23390
- debugUIGroup(
23391
- `${controlType}.registerChild("${childControlType}") -> registered (total: ${childUIStateControllerArray.length})`,
23392
- );
23393
- // Auto-derive child's initial state from the group's value/defaultValue
23394
- // when the child has no individually-controlled state prop.
23395
- // Use initial_state_push so uiAction does not fire during mount initialization.
23396
- if (!childUIStateController.hasStateProp) {
23397
- const initialEvent = new CustomEvent("initial_state_push", {
23398
- detail: {},
23399
- });
23400
- if (groupUIStateController.hasValueProp) {
23401
- // Controlled: always cascade current group value (even undefined = deselect all).
23402
- const childNewState = resolvedDistributeChildUIState(
23403
- groupUIStateController.value,
23336
+ if (delegatedTo) {
23337
+ delegatedTo.onChildUIAction(childUIStateController, e, {
23338
+ stateChanged,
23339
+ silent,
23340
+ });
23341
+ return;
23342
+ }
23343
+ if (!isMonitoringChild(childUIStateController)) return;
23344
+ const childControlType = childUIStateController.controlType;
23345
+ debugUIGroup(
23346
+ `${controlType}.onChildUIAction("${childControlType}") stateChanged=${stateChanged} -> child state: ${JSON.stringify(
23347
+ childUIStateController.uiState,
23348
+ )}`,
23349
+ );
23350
+ if (stateChanged) {
23351
+ onChange(e, { notifyExternal: silent ? "silent" : true });
23352
+ } else {
23353
+ controller.onUIAction(e);
23354
+ }
23355
+ },
23356
+ unregisterChild: (childUIStateController) => {
23357
+ const delegatedTo = delegatedChildrenRef.current.get(
23404
23358
  childUIStateController,
23405
23359
  );
23406
- if (childNewState !== CANNOT_DERIVE) {
23407
- childUIStateController.setUIState(childNewState, initialEvent);
23360
+ if (delegatedTo) {
23361
+ delegatedChildrenRef.current.delete(childUIStateController);
23362
+ delegatedTo.unregisterChild(childUIStateController);
23363
+ return;
23408
23364
  }
23409
- } else if (groupUIStateController.hasDefaultValueProp) {
23410
- // Uncontrolled: set initial state from defaultValue on mount.
23411
- const childNewState = resolvedDistributeChildUIState(
23412
- groupUIStateController.defaultValue,
23365
+ if (!isMonitoringChild(childUIStateController)) return;
23366
+ const childControlType = childUIStateController.controlType;
23367
+ const index = childUIStateControllerArray.indexOf(
23413
23368
  childUIStateController,
23414
23369
  );
23415
- if (childNewState !== CANNOT_DERIVE) {
23416
- childUIStateController.setUIState(childNewState, initialEvent);
23370
+ if (index === -1) {
23371
+ debugUIGroup(
23372
+ `${controlType}.unregisterChild("${childControlType}") -> not found`,
23373
+ );
23374
+ return;
23417
23375
  }
23418
- }
23419
- }
23420
- onChange(new CustomEvent(`${childControlType}_mount`), {
23421
- notifyExternal: "silent",
23422
- // childUIStateController,
23376
+ childUIStateControllerArray.splice(index, 1);
23377
+ debugUIGroup(
23378
+ `${controlType}.unregisterChild("${childControlType}") -> unregisteed (remaining: ${childUIStateControllerArray.length})`,
23379
+ );
23380
+ onChange(new CustomEvent(`${childControlType}_unmount`), {
23381
+ notifyExternal: "silent",
23382
+ });
23383
+ },
23384
+ resetUIState: (e) => {
23385
+ const ev = new CustomEvent("propagate_down_reset_ui_state", {
23386
+ detail: {},
23387
+ });
23388
+ chainEvent(ev, e);
23389
+ for (const c of childUIStateControllerArray) {
23390
+ if (shouldPropagateStateToChild(c)) c.resetUIState(ev);
23391
+ }
23392
+ onChange(e, { notifyExternal: true });
23393
+ },
23394
+ clearUIState: (e) => {
23395
+ const ev = new CustomEvent("propagate_down_clear_ui_state", {
23396
+ detail: {},
23397
+ });
23398
+ chainEvent(ev, e);
23399
+ for (const c of childUIStateControllerArray) {
23400
+ if (
23401
+ !isMonitoringChild(c) ||
23402
+ c.controlType === "button" ||
23403
+ c.controlType === "link"
23404
+ ) {
23405
+ continue;
23406
+ }
23407
+ c.clearUIState(ev);
23408
+ }
23409
+ onChange(e, { notifyExternal: true });
23410
+ },
23411
+ onActionEnd: (e) => {
23412
+ controller.rules.validation.syncValidity(e);
23413
+ },
23414
+ onActionError: (e) => {
23415
+ controller.rules.validation.syncValidity(e, { report: true });
23416
+ },
23417
+ findChildById: (searchId) => {
23418
+ for (const c of childUIStateControllerArray) {
23419
+ if (c.id === searchId) return c;
23420
+ }
23421
+ return null;
23422
+ },
23423
+ getChildControllers: () => childUIStateControllerArray,
23424
+ getManagedControls: () => {
23425
+ if (!cascadeValidationToChildren) return [];
23426
+ return childUIStateControllerArray.slice();
23427
+ },
23428
+ subscribe: subscribeUIState,
23429
+ };
23430
+ const rules = createControlRules(controller, {
23431
+ debugPopup,
23432
+ debugInteraction,
23433
+ debugUIState: debugUIGroup,
23434
+ debugFocus,
23423
23435
  });
23436
+ controller.rules = rules;
23437
+
23438
+ // Include all values read by controller methods so they are immediately
23439
+ // available, even if the user interacts before the first re-render.
23440
+ return {
23441
+ controller,
23442
+ _onChange: onChange,
23443
+ ref,
23444
+ parentUIStateController,
23445
+ uiAction,
23446
+ uiActionInternal,
23447
+ };
23424
23448
  },
23425
- onChildUIAction: (childUIStateController, e, { stateChanged, silent }) => {
23426
- const delegatedTo = delegatedChildrenRef.current.get(
23427
- childUIStateController,
23428
- );
23429
- if (delegatedTo) {
23430
- // Forward UI action for delegated children — we don't aggregate them,
23431
- // but their parent (who adopted them) needs to know about the change.
23432
- delegatedTo.onChildUIAction(childUIStateController, e, {
23433
- stateChanged,
23434
- silent,
23435
- });
23436
- return;
23437
- }
23438
- if (!isMonitoringChild(childUIStateController)) {
23439
- return;
23440
- }
23441
- const childControlType = childUIStateController.controlType;
23442
- debugUIGroup(
23443
- `${controlType}.onChildUIAction("${childControlType}") stateChanged=${stateChanged} -> child state: ${JSON.stringify(
23444
- childUIStateController.uiState,
23445
- )}`,
23446
- );
23447
- if (stateChanged) {
23448
- if (silent) {
23449
- // Silent update: keep the group's cached state in sync without firing
23450
- // uiAction, command, or action pipeline. Used when e.g. the picker facade
23451
- // propagates a child state change up through an internal event path.
23452
- onChange(e, { notifyExternal: "silent" });
23453
- } else {
23454
- // Value changed: re-aggregate and fire all reactions (uiAction, command, action pipeline).
23455
- onChange(e, { notifyExternal: true });
23449
+ // ── update: runs every render after the first ─────────────────────────
23450
+ (s) => {
23451
+ const { controller } = s;
23452
+ const prevValue = controller.value;
23453
+ const prevHasValueProp = controller.hasValueProp;
23454
+ controller.props = props;
23455
+ controller.ref = ref;
23456
+ controller.id = id;
23457
+ controller.name = name;
23458
+ controller.value = value;
23459
+ controller.defaultValue = defaultValue;
23460
+ controller.hasValueProp = hasValueProp;
23461
+ controller.hasDefaultValueProp = hasDefaultValueProp;
23462
+ if (
23463
+ hasValueProp &&
23464
+ (!prevHasValueProp || !compareTwoJsValues(value, prevValue))
23465
+ ) {
23466
+ const propagateDownEvent = new CustomEvent(
23467
+ "propagate_down_set_ui_state",
23468
+ { detail: {} },
23469
+ );
23470
+ for (const childUIStateController of childUIStateControllerArray) {
23471
+ if (!shouldPropagateStateToChild(childUIStateController)) continue;
23472
+ if (childUIStateController.hasStateProp) continue;
23473
+ const childNewState = controller.distributeChildUIState(
23474
+ value,
23475
+ childUIStateController,
23476
+ );
23477
+ if (childNewState === CANNOT_DERIVE) continue;
23478
+ childUIStateController.setUIState(childNewState, propagateDownEvent);
23456
23479
  }
23457
- } else {
23458
- // Value unchanged (e.g. radio re-clicked): fire uiAction + command only.
23459
- groupUIStateController.onUIAction(e);
23480
+ controller.syncInternalState(value);
23460
23481
  }
23482
+
23483
+ return {
23484
+ ref,
23485
+ parentUIStateController,
23486
+ uiAction,
23487
+ uiActionInternal,
23488
+ id,
23489
+ name,
23490
+ value,
23491
+ defaultValue,
23492
+ hasValueProp,
23493
+ hasDefaultValueProp,
23494
+ props,
23495
+ };
23461
23496
  },
23462
- unregisterChild: (childUIStateController) => {
23463
- const delegatedTo = delegatedChildrenRef.current.get(
23464
- childUIStateController,
23465
- );
23466
- if (delegatedTo) {
23467
- delegatedChildrenRef.current.delete(childUIStateController);
23468
- delegatedTo.unregisterChild(childUIStateController);
23469
- return;
23470
- }
23471
- if (!isMonitoringChild(childUIStateController)) {
23472
- return;
23473
- }
23474
- const childControlType = childUIStateController.controlType;
23475
- const index = childUIStateControllerArray.indexOf(childUIStateController);
23476
- if (index === -1) {
23477
- debugUIGroup(
23478
- `${controlType}.unregisterChild("${childControlType}") -> not found`,
23479
- );
23480
- return;
23481
- }
23482
- childUIStateControllerArray.splice(index, 1);
23497
+ );
23498
+
23499
+ const { controller } = scope;
23500
+ useLayoutEffect(() => {
23501
+ const el = ref.current;
23502
+ if (el) {
23503
+ el.__uiStateController__ = controller;
23504
+ }
23505
+ return () => {
23506
+ onUIStateControllerDestroyed(controller);
23507
+ };
23508
+ }, []);
23509
+
23510
+ const { parentUIStateController: parentController } = scope;
23511
+ useLayoutEffect(() => {
23512
+ if (!parentController) {
23513
+ return undefined;
23514
+ }
23515
+
23516
+ debugUIGroup(
23517
+ `"${controlType}" registering into "${parentController.controlType}"`,
23518
+ );
23519
+ parentController.registerChild(controller);
23520
+ return () => {
23483
23521
  debugUIGroup(
23484
- `${controlType}.unregisterChild("${childControlType}") -> unregisteed (remaining: ${childUIStateControllerArray.length})`,
23485
- );
23486
- onChange(new CustomEvent(`${childControlType}_unmount`), {
23487
- notifyExternal: "silent",
23488
- // childUIStateController,
23489
- });
23490
- },
23491
- resetUIState: (e) => {
23492
- const propagateDownResetEvent = new CustomEvent(
23493
- "propagate_down_reset_ui_state",
23494
- { detail: {} },
23522
+ `"${controlType}" unregistering from "${parentController.controlType}"`,
23495
23523
  );
23496
- chainEvent(propagateDownResetEvent, e);
23497
- for (const childUIStateController of childUIStateControllerArray) {
23498
- if (!shouldPropagateStateToChild(childUIStateController)) {
23499
- continue;
23500
- }
23501
- childUIStateController.resetUIState(propagateDownResetEvent);
23502
- }
23503
- onChange(e, { notifyExternal: true });
23504
- },
23505
- clearUIState: (e) => {
23506
- const propagateDownClearEvent = new CustomEvent(
23507
- "propagate_down_clear_ui_state",
23508
- { detail: {} },
23524
+ parentController.unregisterChild(controller);
23525
+ };
23526
+ }, [parentController]);
23527
+
23528
+ useLayoutEffect(() => {
23529
+ groupIsRenderingRef.current = false;
23530
+ if (pendingChangeRef.current) {
23531
+ pendingChangeRef.current = false;
23532
+ scope._onChange(
23533
+ new CustomEvent(`${controlType}_batched_ui_state_update`),
23534
+ { notifyExternal: "silent" },
23509
23535
  );
23510
- chainEvent(propagateDownClearEvent, e);
23511
- for (const childUIStateController of childUIStateControllerArray) {
23512
- if (!isMonitoringChild(childUIStateController)) {
23513
- continue;
23514
- }
23515
- if (childUIStateController.controlType === "button") {
23516
- continue;
23517
- }
23518
- if (childUIStateController.controlType === "link") {
23519
- continue;
23520
- }
23521
- childUIStateController.clearUIState(propagateDownClearEvent);
23522
- }
23523
- onChange(e, { notifyExternal: true });
23524
- },
23525
- onActionEnd: (e) => {
23526
- groupUIStateController.rules.validation.syncValidity(e);
23527
- },
23528
- onActionError: (e) => {
23529
- groupUIStateController.rules.validation.syncValidity(e, { report: true });
23530
- },
23531
- findChildById: (id) => {
23532
- for (const childUIStateController of childUIStateControllerArray) {
23533
- if (childUIStateController.id === id) {
23534
- return childUIStateController;
23535
- }
23536
- }
23537
- return null;
23538
- },
23539
- getChildControllers: () => childUIStateControllerArray,
23540
- getManagedControls: () => {
23541
- if (!cascadeValidationToChildren) {
23542
- return [];
23543
- }
23544
- return childUIStateControllerArray.slice();
23545
- },
23546
- subscribe: subscribeUIState,
23547
- };
23548
- controllerRef.current = groupUIStateController;
23549
- const rules = createControlRules(groupUIStateController, {
23550
- debugPopup,
23551
- debugInteraction,
23552
- debugUIState: debugUIGroup,
23553
- debugFocus,
23536
+ }
23554
23537
  });
23555
- groupUIStateController.rules = rules;
23556
- return groupUIStateController;
23538
+
23539
+ return controller;
23557
23540
  };
23558
23541
  // Stable reference for an empty selection so the action always receives an
23559
23542
  // array (never undefined) and callers don't get a new reference each render.
@@ -23592,6 +23575,141 @@ const useUIFacadeStateController = (props, realUIStateController) => {
23592
23575
  const debugUIState = useDebugUIState();
23593
23576
  const debugFocus = useDebugFocus();
23594
23577
 
23578
+ // The facade controller's closures (registerChild/unregisterChild/onChildUIAction)
23579
+ // must not capture `realUIStateController` directly: that parameter can legitimately
23580
+ // point to a different controller instance on a later render (e.g. the picker's own
23581
+ // controller getting recreated). Instead, closures read `s.realUIStateController`
23582
+ // from the stable scope object, which is kept current by `update` on every render.
23583
+ const scope = useRenderScope(
23584
+ // ── init: runs once on mount ───────────────────────────────────────────
23585
+ (s) => {
23586
+ const canRegisterAsFacadeChild = (childController) => {
23587
+ if (childController.controlType === "button") return false;
23588
+ if (childController.controlType === "link") return false;
23589
+ if (childController.controlType === "facade") return false;
23590
+ if (childController.isProxy) return false;
23591
+ if (childController.props["navi-list"]) {
23592
+ // Controls with navi-list act as standalone list navigators and should
23593
+ // not be treated as the picker's synced child.
23594
+ return false;
23595
+ }
23596
+ if (
23597
+ props.type === "controlgroup" &&
23598
+ childController.controlType !== "control_group"
23599
+ ) {
23600
+ // ignore non control group registration (input outside the control group for instance)
23601
+ return false;
23602
+ }
23603
+ if (
23604
+ props.type === "array" &&
23605
+ childController.controlType !== "checkbox_group"
23606
+ ) {
23607
+ // only selectable list expose array, ignore others
23608
+ return false;
23609
+ }
23610
+ return true;
23611
+ };
23612
+
23613
+ const facadeUIStateController = {
23614
+ controlType: "facade",
23615
+ props,
23616
+ ref: realUIStateController.ref,
23617
+ uiStateSignal: realUIStateController.uiStateSignal,
23618
+ controlHostProps: realUIStateController.controlHostProps,
23619
+ registerChild: (child) => {
23620
+ if (!canRegisterAsFacadeChild(child)) {
23621
+ return;
23622
+ }
23623
+ const childType = child.controlType;
23624
+ if (firstChildControllerRef.current) {
23625
+ console.warn(
23626
+ `[useUIFacadeStateController] A second child ("${childType}"${child.name ? ` name="${child.name}"` : ""}) tried to register in the picker facade. ` +
23627
+ `The facade only syncs with the first child — wrap multiple controls in a single ControlGroup.`,
23628
+ child,
23629
+ );
23630
+ } else {
23631
+ debugUIState(
23632
+ `[useUIFacadeStateController] "${childType}"${child.name ? ` name="${child.name}"` : ""} registered as the first child in the picker facade.`,
23633
+ );
23634
+ firstChildControllerRef.current = child;
23635
+ s.realUIStateController.facadeChild = child;
23636
+ // If the picker already has a meaningful state (from value or defaultValue),
23637
+ // push it to the child on registration so it reflects the pre-set value
23638
+ // without firing uiAction (equivalent to defaultValue on the child itself).
23639
+ const initialState = s.realUIStateController.uiState;
23640
+ if (initialState !== undefined) {
23641
+ updatingRef.current = true;
23642
+ const initialEvent = new CustomEvent("initial_state_push", {
23643
+ detail: {},
23644
+ });
23645
+ child.setUIState(initialState, initialEvent);
23646
+ updatingRef.current = false;
23647
+ }
23648
+ }
23649
+ },
23650
+ unregisterChild: (child) => {
23651
+ if (firstChildControllerRef.current === child) {
23652
+ firstChildControllerRef.current = null;
23653
+ s.realUIStateController.facadeChild = null;
23654
+ }
23655
+ },
23656
+ getManagedControls: () => {
23657
+ const child = firstChildControllerRef.current;
23658
+ if (!child) {
23659
+ return [];
23660
+ }
23661
+ return child.getManagedControls();
23662
+ },
23663
+ onChildUIAction: (child, e, { stateChanged, silent = false }) => {
23664
+ if (!stateChanged) {
23665
+ return;
23666
+ }
23667
+ if (child !== firstChildControllerRef.current) {
23668
+ return;
23669
+ }
23670
+ updatingRef.current = true;
23671
+ // Use a different event type for silent (mount/unmount) syncs so that
23672
+ // the picker's setUIState does not fire navi_change or action pipelines.
23673
+ const eventType = silent
23674
+ ? "facade_child_mount_sync"
23675
+ : "facade_propagate_up";
23676
+ const propagateUpEvent = new CustomEvent(eventType, {
23677
+ detail: {},
23678
+ });
23679
+ chainEvent(propagateUpEvent, e);
23680
+ s.realUIStateController.setUIState(child.uiState, propagateUpEvent);
23681
+ updatingRef.current = false;
23682
+ },
23683
+ };
23684
+ const rules = createControlRules(facadeUIStateController, {
23685
+ debugPopup,
23686
+ debugInteraction,
23687
+ debugUIState,
23688
+ debugFocus,
23689
+ });
23690
+ facadeUIStateController.rules = rules;
23691
+
23692
+ // No initial checkValidity() here — the facade has no controlHostProps and no children
23693
+ // have registered yet, so any check would be a no-op. The real validity check happens
23694
+ // when child controllers trigger UI actions through the facade.
23695
+ return {
23696
+ controller: facadeUIStateController,
23697
+ realUIStateController,
23698
+ };
23699
+ },
23700
+ // ── update: runs every render after the first ─────────────────────────
23701
+ (s) => {
23702
+ s.controller.props = props;
23703
+ s.controller.ref = realUIStateController.ref;
23704
+ s.controller.uiStateSignal = realUIStateController.uiStateSignal;
23705
+ s.controller.controlHostProps = realUIStateController.controlHostProps;
23706
+
23707
+ return {
23708
+ realUIStateController,
23709
+ };
23710
+ },
23711
+ );
23712
+
23595
23713
  useLayoutEffect(() => {
23596
23714
  return realUIStateController.subscribe((newUIState, e) => {
23597
23715
  if (updatingRef.current) {
@@ -23610,132 +23728,9 @@ const useUIFacadeStateController = (props, realUIStateController) => {
23610
23728
  child.setUIState(newUIState, propagateDownEvent);
23611
23729
  updatingRef.current = false;
23612
23730
  });
23613
- }, []);
23614
-
23615
- const canRegisterAsFacadeChild = (childController) => {
23616
- if (childController.controlType === "button") {
23617
- return false;
23618
- }
23619
- if (childController.controlType === "link") {
23620
- return false;
23621
- }
23622
- if (childController.controlType === "facade") {
23623
- return false;
23624
- }
23625
- if (childController.isProxy) {
23626
- return false;
23627
- }
23628
- if (childController.props["navi-list"]) {
23629
- // Controls with navi-list act as standalone list navigators and should
23630
- // not be treated as the picker's synced child.
23631
- return false;
23632
- }
23633
- if (
23634
- props.type === "controlgroup" &&
23635
- childController.controlType !== "control_group"
23636
- ) {
23637
- // ignore non control group registration (input outside the control group for instance)
23638
- return false;
23639
- }
23640
- if (
23641
- props.type === "array" &&
23642
- childController.controlType !== "checkbox_group"
23643
- ) {
23644
- // only selectable list expose array, ignore others
23645
- return false;
23646
- }
23647
- return true;
23648
- };
23649
-
23650
- const controllerRef = useRef();
23651
- if (controllerRef.current) {
23652
- return controllerRef.current;
23653
- }
23731
+ }, [realUIStateController]);
23654
23732
 
23655
- const facadeUIStateController = {
23656
- controlType: "facade",
23657
- props,
23658
- elementRef: realUIStateController.elementRef,
23659
- uiStateSignal: realUIStateController.uiStateSignal,
23660
- registerChild: (child) => {
23661
- if (!canRegisterAsFacadeChild(child)) {
23662
- return;
23663
- }
23664
- const childType = child.controlType;
23665
- if (firstChildControllerRef.current) {
23666
- console.warn(
23667
- `[useUIFacadeStateController] A second child ("${childType}"${child.name ? ` name="${child.name}"` : ""}) tried to register in the picker facade. ` +
23668
- `The facade only syncs with the first child — wrap multiple controls in a single ControlGroup.`,
23669
- child,
23670
- );
23671
- } else {
23672
- debugUIState(
23673
- `[useUIFacadeStateController] "${childType}"${child.name ? ` name="${child.name}"` : ""} registered as the first child in the picker facade.`,
23674
- );
23675
- firstChildControllerRef.current = child;
23676
- realUIStateController.facadeChild = child;
23677
- // If the picker already has a meaningful state (from value or defaultValue),
23678
- // push it to the child on registration so it reflects the pre-set value
23679
- // without firing uiAction (equivalent to defaultValue on the child itself).
23680
- const initialState = realUIStateController.uiState;
23681
- if (initialState !== undefined) {
23682
- updatingRef.current = true;
23683
- const initialEvent = new CustomEvent("initial_state_push", {
23684
- detail: {},
23685
- });
23686
- child.setUIState(initialState, initialEvent);
23687
- updatingRef.current = false;
23688
- }
23689
- }
23690
- },
23691
- unregisterChild: (child) => {
23692
- if (firstChildControllerRef.current === child) {
23693
- firstChildControllerRef.current = null;
23694
- realUIStateController.facadeChild = null;
23695
- }
23696
- },
23697
- getManagedControls: () => {
23698
- const child = firstChildControllerRef.current;
23699
- if (!child) {
23700
- return [];
23701
- }
23702
- return child.getManagedControls();
23703
- },
23704
- onChildUIAction: (child, e, { stateChanged, silent = false }) => {
23705
- if (!stateChanged) {
23706
- return;
23707
- }
23708
- if (child !== firstChildControllerRef.current) {
23709
- return;
23710
- }
23711
- updatingRef.current = true;
23712
- // Use a different event type for silent (mount/unmount) syncs so that
23713
- // the picker's setUIState does not fire navi_change or action pipelines.
23714
- const eventType = silent
23715
- ? "facade_child_mount_sync"
23716
- : "facade_propagate_up";
23717
- const propagateUpEvent = new CustomEvent(eventType, {
23718
- detail: {},
23719
- });
23720
- chainEvent(propagateUpEvent, e);
23721
- realUIStateController.setUIState(child.uiState, propagateUpEvent);
23722
- updatingRef.current = false;
23723
- },
23724
- };
23725
- controllerRef.current = facadeUIStateController;
23726
- const rules = createControlRules(facadeUIStateController, {
23727
- debugPopup,
23728
- debugInteraction,
23729
- debugUIState,
23730
- debugFocus,
23731
- });
23732
- facadeUIStateController.rules = rules;
23733
- facadeUIStateController.controlHostProps =
23734
- realUIStateController.controlHostProps;
23735
- // No initial checkValidity() here — the facade has no controlHostProps and no children
23736
- // have registered yet, so any check would be a no-op. The real validity check happens
23737
- // when child controllers trigger UI actions through the facade.
23738
- return facadeUIStateController;
23733
+ return scope.controller;
23739
23734
  };
23740
23735
 
23741
23736
  /**
@@ -23977,7 +23972,7 @@ const useControlProps = (props, {
23977
23972
  return true;
23978
23973
  };
23979
23974
  const syncUIStateWithDOM = e => {
23980
- const controlEl = e.currentTarget || uiStateController.elementRef.current;
23975
+ const controlEl = e.currentTarget || uiStateController.ref.current;
23981
23976
  const value = readControlValue(controlEl);
23982
23977
  uiStateController.setUIState(value, e);
23983
23978
  };
@@ -23987,7 +23982,6 @@ const useControlProps = (props, {
23987
23982
  const triggerUIAction = e => {
23988
23983
  syncUIStateWithDOM(e);
23989
23984
  };
23990
- const wasCheckedAtMousedownRef = useRef(false);
23991
23985
  const getDefaultEventReactionDefinitions = () => {
23992
23986
  const keyDownDefault = e => {
23993
23987
  const defaultAction = getKeyboardEventDefaultAction(e);
@@ -24202,16 +24196,20 @@ const useControlProps = (props, {
24202
24196
  }
24203
24197
  return keyDownDefault(e);
24204
24198
  },
24205
- mouseDown: e => {
24206
- wasCheckedAtMousedownRef.current = e.currentTarget.checked;
24207
- },
24208
24199
  click: e => {
24209
- if (isRadio && wasCheckedAtMousedownRef.current) {
24210
- // When a radio is already checked and gets clicked, the browser does NOT
24211
- // fire an input event (state doesn't change), so asAction never runs.
24212
- // We still want uiAction + command to fire. We can tell whether the click
24213
- // is on an already-checked radio by looking at wasCheckedAtMousedownRef:
24214
- // if it was checked at mousedown, the input event won't come, so we do it here.
24200
+ // When a radio is already checked and gets clicked, the browser does NOT
24201
+ // fire an input event (state doesn't change), so syncUIStateWithDOM never
24202
+ // runs. We still want uiAction + command to fire. We can tell whether the
24203
+ // click is on an already-checked radio by comparing our own tracked
24204
+ // uiState (value when checked, undefined when not see toDomProps) to
24205
+ // this radio's value: it's still the PRE-click value here, since it's
24206
+ // only updated later by the "input" handler. Reading the DOM `.checked`
24207
+ // instead would not work: the browser applies the checked toggle before
24208
+ // dispatching "click", so `.checked` is already the POST-click value
24209
+ // whether or not this was already the checked radio — and worse, a click
24210
+ // that lands on the <label> (not the <input>) never fires "mousedown" on
24211
+ // the input at all, so a DOM-snapshot-at-mousedown approach misses it.
24212
+ if (isRadio && uiStateController.uiState !== undefined) {
24215
24213
  return {
24216
24214
  name: `click on checked radio`,
24217
24215
  allowed: () => triggerUIAction(e),
@@ -24394,7 +24392,7 @@ const useControlProps = (props, {
24394
24392
  debounce: actionDebounce,
24395
24393
  debugInteraction
24396
24394
  });
24397
- }, [actionEvent, actionAfterChange, actionDebounce, hasNaviChangeEventReaction]);
24395
+ }, [actionEvent, actionAfterChange, actionDebounce, hasNaviChangeEventReaction, ref]);
24398
24396
  const refComposed = useComposeElementRef(refCallback, ref);
24399
24397
  const onPaste = e => {
24400
24398
  props.onPaste?.(e);
@@ -24800,7 +24798,7 @@ const useInteractiveProps = (props, {
24800
24798
  }
24801
24799
  }));
24802
24800
  }
24803
- }, [disabledResolved, readOnlyResolved]);
24801
+ }, [disabledResolved, readOnlyResolved, ref]);
24804
24802
  useLayoutEffect(() => {
24805
24803
  return () => {
24806
24804
  const element = ref.current;
@@ -25002,7 +25000,7 @@ const useInteractiveProps = (props, {
25002
25000
  // already aggregated the new state by now, so uiStateSignal is correct.
25003
25001
  const parentController = uiStateController.parentUIStateController;
25004
25002
  if (parentController && (parentController.controlType === "radio_group" || parentController.controlType === "checkbox_group")) {
25005
- const parentEl = parentController.elementRef.current;
25003
+ const parentEl = parentController.ref.current;
25006
25004
  if (parentEl) {
25007
25005
  const originalEvent = e.detail.eventChain[0];
25008
25006
  dispatchRequestAction(parentEl, {
@@ -33944,20 +33942,24 @@ const InputTextualFirstResolver = props => {
33944
33942
  });
33945
33943
  };
33946
33944
  const InputTextual = createComponentResolver([InputTextualFirstResolver, InputWithListResolver, InputWithSuggestionsResolver, InputTypeResolver, InputModeResolver, InputHeadlessResolver, InputTextualUI]);
33947
- const RealInput = props => {
33948
- const autoSelectReadOnlyProps = useAutoSelectReadOnly(props);
33945
+ const RealInput = ({
33946
+ maxLength,
33947
+ ...domProps
33948
+ }) => {
33949
+ const autoSelectReadOnlyProps = useAutoSelectReadOnly(domProps);
33949
33950
  return jsx(Box, {
33950
- ...props,
33951
+ ...domProps,
33951
33952
  as: "input",
33952
33953
  baseClassName: "navi_control_input",
33953
33954
  ...autoSelectReadOnlyProps,
33954
- // Never set native maxLength — our guard handles it. maxLength stays in
33955
- // inputControlHostProps so form validation constraints still read it.
33956
- maxLength: undefined
33957
- // But do expose it (needed by navi_input_full event)
33958
- ,
33959
-
33960
- "navi-max-length": props.maxLength
33955
+ // Never set native maxLength — our guard handles it. Omitting it entirely
33956
+ // avoids a Preact quirk: setting maxLength={undefined} on a fresh DOM element
33957
+ // (e.g. after a Suspense remount) causes Preact to run `el.maxLength = ""`
33958
+ // which coerces to 0 (Number("") = 0), capping the input at 0 characters.
33959
+ // see https://github.com/preactjs/preact/issues/2677
33960
+ // The JS value stays accessible via the navi-max-length attribute and via
33961
+ // inputControlHostProps (which the validation system reads directly).
33962
+ "navi-max-length": maxLength
33961
33963
  });
33962
33964
  };
33963
33965
  const InputStyleCSSVars = {
@@ -36209,6 +36211,11 @@ const PickerCustom = props => {
36209
36211
  }
36210
36212
  const pickerEl = ref.current;
36211
36213
  const inputEl = getPickerInput(pickerEl);
36214
+ const valueAtClose = getUIStateFromElement(inputEl);
36215
+ if (compareTwoJsValues(valueAtClose, valueAtOpen)) {
36216
+ // Value unchanged — no action to run, but still allow the close.
36217
+ return;
36218
+ }
36212
36219
  dispatchRequestAction(inputEl, {
36213
36220
  event: requestCloseEvent,
36214
36221
  name: "picker request close",