@jsenv/navi 0.27.67 → 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.
- package/dist/jsenv_navi.js +1034 -1043
- package/dist/jsenv_navi.js.map +7 -6
- package/package.json +1 -1
package/dist/jsenv_navi.js
CHANGED
|
@@ -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
|
|
@@ -22413,531 +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 isProxy = Boolean(props["navi-control-proxy-for"]);
|
|
22421
22448
|
if (persists === undefined && formContext) {
|
|
22422
22449
|
persists = true;
|
|
22423
22450
|
}
|
|
22424
22451
|
const controlType = controlInfo.controlType;
|
|
22425
22452
|
const isRadio = controlType === "input" && props.type === "radio";
|
|
22426
|
-
const [
|
|
22427
|
-
notifyParentAboutChildMount,
|
|
22428
|
-
notifyParentAboutChildUIAction,
|
|
22429
|
-
notifyParentAboutChildUnmount,
|
|
22430
|
-
] = useParentControllerNotifiers(
|
|
22431
|
-
parentUIStateController,
|
|
22432
|
-
uiStateControllerRef,
|
|
22433
|
-
controlType,
|
|
22434
|
-
debugUIState,
|
|
22435
|
-
);
|
|
22436
|
-
useLayoutEffect(() => {
|
|
22437
|
-
const controller = uiStateControllerRef.current;
|
|
22438
|
-
const el = controller.ref.current;
|
|
22439
|
-
if (el) {
|
|
22440
|
-
el.__uiStateController__ = controller;
|
|
22441
|
-
}
|
|
22442
|
-
notifyParentAboutChildMount();
|
|
22443
|
-
return () => {
|
|
22444
|
-
if (el && el.__uiStateController__ === controller) {
|
|
22445
|
-
delete el.__uiStateController__;
|
|
22446
|
-
}
|
|
22447
|
-
notifyParentAboutChildUnmount();
|
|
22448
|
-
onUIStateControllerDestroyed(controller);
|
|
22449
|
-
};
|
|
22450
|
-
}, []);
|
|
22453
|
+
const isProxy = Boolean(props["navi-control-proxy-for"]);
|
|
22451
22454
|
|
|
22452
|
-
const
|
|
22453
|
-
|
|
22454
|
-
|
|
22455
|
-
|
|
22456
|
-
|
|
22457
|
-
|
|
22458
|
-
|
|
22459
|
-
|
|
22460
|
-
|
|
22461
|
-
|
|
22462
|
-
|
|
22463
|
-
|
|
22464
|
-
|
|
22465
|
-
|
|
22466
|
-
|
|
22467
|
-
|
|
22468
|
-
|
|
22469
|
-
|
|
22470
|
-
|
|
22471
|
-
|
|
22472
|
-
|
|
22473
|
-
|
|
22474
|
-
|
|
22475
|
-
|
|
22476
|
-
|
|
22477
|
-
|
|
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;
|
|
22478
22483
|
|
|
22479
|
-
|
|
22480
|
-
|
|
22481
|
-
|
|
22482
|
-
|
|
22483
|
-
|
|
22484
|
-
|
|
22485
|
-
// Re-sync to this render's ref object. It's normally stable, but it can
|
|
22486
|
-
// legitimately change identity (e.g. switching from an internal fallback
|
|
22487
|
-
// ref to a forwarded one, or across an interrupted/resumed render such as
|
|
22488
|
-
// a Suspense boundary resolving) — if we kept the original ref forever,
|
|
22489
|
-
// `ref.current` would be stuck at whatever it was at creation time, even
|
|
22490
|
-
// after the controller has moved on to a different, live DOM node.
|
|
22491
|
-
uiStateController.ref = props.ref;
|
|
22492
|
-
uiStateController.id = props.id; // never suppoed to changed, not supported for now
|
|
22493
|
-
uiStateController.name = props.name;
|
|
22484
|
+
const controller = {
|
|
22485
|
+
controlType,
|
|
22486
|
+
parentUIStateController,
|
|
22487
|
+
parentUiStateSignalHolder,
|
|
22488
|
+
isProxy,
|
|
22489
|
+
allowNameless,
|
|
22494
22490
|
|
|
22495
|
-
|
|
22496
|
-
|
|
22497
|
-
|
|
22498
|
-
|
|
22499
|
-
|
|
22500
|
-
|
|
22501
|
-
|
|
22502
|
-
|
|
22503
|
-
|
|
22504
|
-
|
|
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,
|
|
22505
22552
|
);
|
|
22506
|
-
|
|
22507
|
-
|
|
22508
|
-
|
|
22509
|
-
|
|
22510
|
-
|
|
22511
|
-
|
|
22512
|
-
|
|
22513
|
-
|
|
22514
|
-
|
|
22515
|
-
|
|
22516
|
-
allowNameless,
|
|
22517
|
-
|
|
22518
|
-
props,
|
|
22519
|
-
ref: props.ref,
|
|
22520
|
-
id: props.id,
|
|
22521
|
-
name: props.name,
|
|
22522
|
-
|
|
22523
|
-
state: stateInitial,
|
|
22524
|
-
uiState: stateInitial,
|
|
22525
|
-
uiStateSignal,
|
|
22526
|
-
value: controlInfo.value,
|
|
22527
|
-
|
|
22528
|
-
facadeChild: null,
|
|
22529
|
-
getManagedControls: () => {
|
|
22530
|
-
if (uiStateController.facadeChild) {
|
|
22531
|
-
const child = uiStateController.facadeChild;
|
|
22532
|
-
const childManaged = child.getManagedControls();
|
|
22533
|
-
if (childManaged.length > 0) {
|
|
22534
|
-
return childManaged;
|
|
22535
|
-
}
|
|
22536
|
-
return [child];
|
|
22537
|
-
}
|
|
22538
|
-
return [];
|
|
22539
|
-
},
|
|
22540
|
-
onUIAction: (e, { skipCommand } = {}) => {
|
|
22541
|
-
if (controlType === "button" && uiStateController.controlHostProps.name) {
|
|
22542
|
-
const buttonName = uiStateController.controlHostProps.name;
|
|
22543
|
-
const parentController = uiStateController.parentUIStateController;
|
|
22544
|
-
if (parentController && parentController.wantRequesterButtonState) {
|
|
22545
|
-
const currentState = parentController.uiState;
|
|
22546
|
-
const mergedState = {
|
|
22547
|
-
...currentState,
|
|
22548
|
-
[buttonName]: uiStateController.uiState,
|
|
22549
|
-
};
|
|
22550
|
-
parentController.syncInternalState(mergedState);
|
|
22551
|
-
debugUIState(
|
|
22552
|
-
`merging button state into parent control group:`,
|
|
22553
|
-
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,
|
|
22554
22563
|
);
|
|
22555
|
-
|
|
22556
|
-
|
|
22557
|
-
|
|
22558
|
-
|
|
22559
|
-
|
|
22560
|
-
|
|
22561
|
-
|
|
22562
|
-
|
|
22563
|
-
|
|
22564
|
-
if (skipCommand) ; else {
|
|
22565
|
-
const command = uiStateController.controlHostProps.command;
|
|
22566
|
-
if (command) {
|
|
22567
|
-
const element = uiStateController.ref.current;
|
|
22568
|
-
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
|
+
}
|
|
22569
22573
|
debugUIState(
|
|
22570
|
-
|
|
22574
|
+
e,
|
|
22575
|
+
`${controllerSig}.setUIState(${JSON.stringify(newUIState)}, "${e.type}") -> state unchanged, no update needed`,
|
|
22571
22576
|
);
|
|
22572
|
-
|
|
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;
|
|
22573
22607
|
}
|
|
22574
|
-
|
|
22575
|
-
|
|
22576
|
-
|
|
22577
|
-
|
|
22578
|
-
|
|
22579
|
-
|
|
22580
|
-
|
|
22581
|
-
|
|
22582
|
-
|
|
22583
|
-
|
|
22584
|
-
|
|
22585
|
-
//
|
|
22586
|
-
|
|
22587
|
-
|
|
22588
|
-
|
|
22589
|
-
|
|
22590
|
-
|
|
22591
|
-
|
|
22592
|
-
|
|
22593
|
-
|
|
22594
|
-
|
|
22595
|
-
|
|
22596
|
-
|
|
22597
|
-
|
|
22598
|
-
|
|
22599
|
-
|
|
22600
|
-
|
|
22601
|
-
|
|
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
|
+
}
|
|
22602
22642
|
}
|
|
22603
|
-
|
|
22604
|
-
|
|
22605
|
-
|
|
22606
|
-
|
|
22607
|
-
|
|
22608
|
-
|
|
22609
|
-
|
|
22610
|
-
|
|
22611
|
-
|
|
22612
|
-
|
|
22613
|
-
|
|
22614
|
-
|
|
22615
|
-
|
|
22616
|
-
|
|
22617
|
-
|
|
22618
|
-
|
|
22619
|
-
|
|
22620
|
-
|
|
22621
|
-
|
|
22622
|
-
|
|
22623
|
-
|
|
22624
|
-
|
|
22625
|
-
|
|
22626
|
-
|
|
22627
|
-
|
|
22628
|
-
|
|
22629
|
-
|
|
22630
|
-
|
|
22631
|
-
|
|
22632
|
-
|
|
22633
|
-
|
|
22634
|
-
|
|
22635
|
-
|
|
22636
|
-
|
|
22637
|
-
|
|
22638
|
-
|
|
22639
|
-
|
|
22640
|
-
|
|
22641
|
-
|
|
22642
|
-
|
|
22643
|
-
|
|
22644
|
-
|
|
22645
|
-
|
|
22646
|
-
|
|
22647
|
-
|
|
22648
|
-
|
|
22649
|
-
|
|
22650
|
-
|
|
22651
|
-
|
|
22652
|
-
|
|
22653
|
-
|
|
22654
|
-
|
|
22655
|
-
|
|
22656
|
-
|
|
22657
|
-
|
|
22658
|
-
|
|
22659
|
-
|
|
22660
|
-
|
|
22661
|
-
|
|
22662
|
-
|
|
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
|
+
});
|
|
22663
22712
|
}
|
|
22664
22713
|
if (
|
|
22665
|
-
|
|
22666
|
-
parentUIStateController
|
|
22714
|
+
e.type === "state_prop_change" &&
|
|
22715
|
+
s.parentUIStateController &&
|
|
22716
|
+
!s.parentUIStateController.hasStateProp
|
|
22667
22717
|
) {
|
|
22668
|
-
|
|
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
|
+
});
|
|
22669
22731
|
}
|
|
22670
|
-
|
|
22671
|
-
}
|
|
22672
|
-
}
|
|
22673
|
-
}
|
|
22674
|
-
debugUIState(e, `publishUIState(${JSON.stringify(newUIState)})`);
|
|
22675
|
-
publishUIState(newUIState, e);
|
|
22676
|
-
const el = uiStateController.ref.current;
|
|
22677
|
-
// Always notify the element that its UI state changed.
|
|
22678
|
-
// Listeners use this to stay in sync (e.g. input_effect.js tracks currentState,
|
|
22679
|
-
// useUIState subscribes for reactive updates). Separate from navi_set_ui_state
|
|
22680
|
-
// which is the command; navi_ui_state_change is the notification.
|
|
22681
|
-
if (el) {
|
|
22682
|
-
dispatchInternalCustomEvent(el, "navi_ui_state_change", {
|
|
22683
|
-
event: e,
|
|
22684
|
-
value: newUIState,
|
|
22685
|
-
});
|
|
22686
|
-
}
|
|
22687
|
-
// When this controller is a real input that has a visible proxy
|
|
22688
|
-
// (linked via `navi-control-proxy-for`), mirror the new state to the
|
|
22689
|
-
// proxy DOM synchronously. Otherwise the proxy would only catch up
|
|
22690
|
-
// later through a React re-render — visible as e.g. two radios
|
|
22691
|
-
// appearing checked at once between the real input update and the
|
|
22692
|
-
// next render (radio_sibling_uncheck case).
|
|
22693
|
-
if (!controlProxyFor) {
|
|
22694
|
-
// Find any mounted controller that declared itself as a proxy for this one.
|
|
22695
|
-
// Communicates directly to the proxy controller — no DOM query needed.
|
|
22696
|
-
const proxyController = findProxyController(id);
|
|
22697
|
-
if (proxyController) {
|
|
22698
|
-
const mirrorEvent = new CustomEvent("proxy_mirror_state", {
|
|
22699
|
-
detail: {},
|
|
22700
|
-
});
|
|
22701
|
-
chainEvent(mirrorEvent, e);
|
|
22702
|
-
proxyController.setUIState(newUIState, mirrorEvent);
|
|
22703
|
-
}
|
|
22704
|
-
}
|
|
22705
|
-
if (isInternalEvent(e)) {
|
|
22706
|
-
if (e.type === "facade_child_mount_sync") {
|
|
22707
|
-
// Warn when the picker's initial signal value is "" but the list
|
|
22708
|
-
// resolved to undefined (no selection). These are semantically
|
|
22709
|
-
// equivalent but technically different, so the mount-sync registers
|
|
22710
|
-
// as a state change and fires uiAction unexpectedly.
|
|
22711
|
-
// Fix: initialise the signal with undefined instead of "".
|
|
22712
|
-
const wasEmptyString =
|
|
22713
|
-
currentUIState === "" && newUIState === undefined;
|
|
22714
|
-
const wasUndefinedNowEmpty =
|
|
22715
|
-
currentUIState === undefined && newUIState === "";
|
|
22716
|
-
if (wasEmptyString) {
|
|
22717
|
-
console.warn(
|
|
22718
|
-
`[navi] Picker mount sync changed state from "" to undefined. ` +
|
|
22719
|
-
`This will call uiAction on mount, which is likely unintended. ` +
|
|
22720
|
-
`Initialise the signal with undefined instead of "" to avoid this.`,
|
|
22721
|
-
);
|
|
22722
|
-
} else if (wasUndefinedNowEmpty) {
|
|
22723
|
-
console.warn(
|
|
22724
|
-
`[navi] Picker mount sync changed state from undefined to "". ` +
|
|
22725
|
-
`This will call uiAction on mount, which is likely unintended. ` +
|
|
22726
|
-
`The child component is emitting "" for an empty value — it should emit undefined instead.`,
|
|
22727
|
-
);
|
|
22732
|
+
return true;
|
|
22728
22733
|
}
|
|
22729
|
-
|
|
22730
|
-
|
|
22731
|
-
// child itself): skip uiAction entirely so no side effects fire on mount.
|
|
22732
|
-
if (e.type !== "initial_state_push") {
|
|
22733
|
-
// Still fire uiAction so external listeners (e.g. signals) stay in
|
|
22734
|
-
// sync, but do NOT fire the command and do NOT notify the parent —
|
|
22735
|
-
// both would cause an infinite loop when a parent cascades state
|
|
22736
|
-
// down to its children (child command would re-trigger the cascade).
|
|
22737
|
-
uiStateController.onUIAction(e, {
|
|
22738
|
-
skipCommand: true,
|
|
22734
|
+
s.parentUIStateController?.onChildUIAction(controller, e, {
|
|
22735
|
+
stateChanged: true,
|
|
22739
22736
|
});
|
|
22740
|
-
|
|
22741
|
-
|
|
22742
|
-
|
|
22743
|
-
|
|
22744
|
-
|
|
22745
|
-
|
|
22746
|
-
|
|
22747
|
-
|
|
22748
|
-
}
|
|
22749
|
-
// Exception: state_prop_change can only fire on a control with its own
|
|
22750
|
-
// controlled state/value prop (see hasStateProp above) — groups never
|
|
22751
|
-
// cascade state down into such children (they're explicitly skipped,
|
|
22752
|
-
// see shouldPropagateStateToChild/hasStateProp checks), so this change
|
|
22753
|
-
// can never be an echo of the parent's own cascade. The loop risk this
|
|
22754
|
-
// suppression exists for only applies when the parent itself just pushed
|
|
22755
|
-
// this value down, which requires the parent to be controlled (have its
|
|
22756
|
-
// own state/value prop). When the parent is "stateless" (uncontrolled),
|
|
22757
|
-
// notifying it is always safe and necessary — otherwise its aggregated
|
|
22758
|
-
// state silently drifts out of sync with this child.
|
|
22759
|
-
if (
|
|
22760
|
-
e.type === "state_prop_change" &&
|
|
22761
|
-
parentUIStateController &&
|
|
22762
|
-
!parentUIStateController.hasStateProp
|
|
22763
|
-
) {
|
|
22764
|
-
notifyParentAboutChildUIAction(e, { stateChanged: true });
|
|
22765
|
-
}
|
|
22766
|
-
return true;
|
|
22767
|
-
}
|
|
22768
|
-
notifyParentAboutChildUIAction(e, { stateChanged: true });
|
|
22769
|
-
if (controlProxyFor) {
|
|
22770
|
-
// Proxy: forward the state change to the real input.
|
|
22771
|
-
// Use a dedicated internal event so that when the real input's setUIState
|
|
22772
|
-
// sees stateIsTheSame=true (already updated by the real input's own flow),
|
|
22773
|
-
// it does NOT fire notifyParentAboutChildUIAction(stateChanged=false) back
|
|
22774
|
-
// to the group — which would trigger the group action with a stale value.
|
|
22775
|
-
const targetController = getUIStateControllerById(controlProxyFor);
|
|
22776
|
-
if (targetController) {
|
|
22777
|
-
debugUIState(
|
|
22778
|
-
e,
|
|
22779
|
-
`forwarding set_ui_state "${newUIState}" to ${getElementSignature(targetController.ref.current)}`,
|
|
22780
|
-
);
|
|
22781
|
-
const forwardEvent = new CustomEvent("proxy_forward_set_ui_state", {
|
|
22782
|
-
detail: {},
|
|
22783
|
-
});
|
|
22784
|
-
chainEvent(forwardEvent, e);
|
|
22785
|
-
targetController.setUIState(newUIState, forwardEvent);
|
|
22786
|
-
}
|
|
22787
|
-
}
|
|
22788
|
-
let syntheticInputFired = false;
|
|
22789
|
-
if (el) {
|
|
22790
|
-
// Dispatch a synthetic "input" event so external listeners see the new
|
|
22791
|
-
// value. Skip when an input event on this element already exists in the chain.
|
|
22792
|
-
const existingInputEvent = findEvent(e, (eInChain) => {
|
|
22793
|
-
return eInChain.type === "input" && eInChain.target === el;
|
|
22794
|
-
});
|
|
22795
|
-
if (!existingInputEvent) {
|
|
22796
|
-
if (el.tagName === "INPUT") {
|
|
22797
|
-
if (el.type === "radio" || el.type === "checkbox") {
|
|
22798
|
-
debugUIState(
|
|
22799
|
-
e,
|
|
22800
|
-
"dispatching synthetic input event without data for checkbox/radio",
|
|
22801
|
-
);
|
|
22802
|
-
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
22803
|
-
syntheticInputFired = true;
|
|
22804
|
-
} else {
|
|
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) {
|
|
22805
22745
|
debugUIState(
|
|
22806
22746
|
e,
|
|
22807
|
-
`
|
|
22747
|
+
`forwarding set_ui_state "${newUIState}" to ${getElementSignature(targetController.ref.current)}`,
|
|
22808
22748
|
);
|
|
22809
|
-
|
|
22810
|
-
|
|
22811
|
-
|
|
22812
|
-
cancelable: true,
|
|
22813
|
-
inputType: "insertText",
|
|
22814
|
-
data: newUIState,
|
|
22815
|
-
}),
|
|
22749
|
+
const forwardEvent = new CustomEvent(
|
|
22750
|
+
"proxy_forward_set_ui_state",
|
|
22751
|
+
{ detail: {} },
|
|
22816
22752
|
);
|
|
22817
|
-
|
|
22753
|
+
chainEvent(forwardEvent, e);
|
|
22754
|
+
targetController.setUIState(newUIState, forwardEvent);
|
|
22818
22755
|
}
|
|
22819
22756
|
}
|
|
22820
|
-
//
|
|
22821
|
-
|
|
22822
|
-
|
|
22823
|
-
|
|
22824
|
-
|
|
22825
|
-
|
|
22826
|
-
|
|
22827
|
-
|
|
22828
|
-
|
|
22829
|
-
|
|
22830
|
-
|
|
22831
|
-
|
|
22832
|
-
|
|
22833
|
-
|
|
22834
|
-
|
|
22835
|
-
|
|
22836
|
-
|
|
22837
|
-
|
|
22838
|
-
|
|
22839
|
-
|
|
22840
|
-
|
|
22841
|
-
|
|
22842
|
-
|
|
22843
|
-
|
|
22844
|
-
|
|
22845
|
-
|
|
22846
|
-
|
|
22847
|
-
|
|
22848
|
-
|
|
22849
|
-
|
|
22850
|
-
|
|
22851
|
-
|
|
22852
|
-
|
|
22853
|
-
|
|
22854
|
-
|
|
22855
|
-
|
|
22856
|
-
|
|
22857
|
-
|
|
22858
|
-
|
|
22859
|
-
|
|
22860
|
-
|
|
22861
|
-
|
|
22862
|
-
|
|
22863
|
-
|
|
22864
|
-
|
|
22865
|
-
|
|
22866
|
-
|
|
22867
|
-
|
|
22868
|
-
|
|
22869
|
-
|
|
22870
|
-
|
|
22871
|
-
|
|
22872
|
-
|
|
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
|
+
};
|
|
22873
22873
|
},
|
|
22874
|
-
|
|
22875
|
-
|
|
22876
|
-
|
|
22877
|
-
|
|
22878
|
-
|
|
22879
|
-
|
|
22880
|
-
|
|
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;
|
|
22881
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
|
+
};
|
|
22882
22915
|
},
|
|
22883
|
-
|
|
22884
|
-
|
|
22885
|
-
|
|
22886
|
-
debugPopup,
|
|
22887
|
-
debugInteraction,
|
|
22888
|
-
debugUIState,
|
|
22889
|
-
debugFocus,
|
|
22890
|
-
});
|
|
22891
|
-
uiStateController.rules = rules;
|
|
22892
|
-
return uiStateController;
|
|
22893
|
-
};
|
|
22894
|
-
const NO_PARENT = [() => {}, () => {}, () => {}];
|
|
22895
|
-
const useParentControllerNotifiers = (
|
|
22896
|
-
parentUIStateController,
|
|
22897
|
-
uiStateControllerRef,
|
|
22898
|
-
controlType,
|
|
22899
|
-
debugUIState,
|
|
22900
|
-
) => {
|
|
22901
|
-
return useMemo(() => {
|
|
22902
|
-
if (!parentUIStateController) {
|
|
22903
|
-
return NO_PARENT;
|
|
22904
|
-
}
|
|
22916
|
+
);
|
|
22917
|
+
scope.parentUiStateSignalHolder.value =
|
|
22918
|
+
parentUIStateController?.uiStateSignal ?? null;
|
|
22905
22919
|
|
|
22906
|
-
|
|
22907
|
-
|
|
22908
|
-
|
|
22909
|
-
|
|
22910
|
-
|
|
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);
|
|
22911
22932
|
};
|
|
22933
|
+
}, [controllerRef]);
|
|
22912
22934
|
|
|
22913
|
-
|
|
22914
|
-
|
|
22915
|
-
|
|
22916
|
-
|
|
22917
|
-
|
|
22918
|
-
debugUIState(
|
|
22919
|
-
`"${controlType}" notifying "${parentControlType}" of child ui action (stateChanged: ${stateChanged})`,
|
|
22920
|
-
);
|
|
22921
|
-
parentUIStateController.onChildUIAction(uiStateController, e, {
|
|
22922
|
-
stateChanged,
|
|
22923
|
-
silent,
|
|
22924
|
-
});
|
|
22925
|
-
};
|
|
22935
|
+
const { parentUIStateController: parentController } = scope;
|
|
22936
|
+
useLayoutEffect(() => {
|
|
22937
|
+
if (!parentController) {
|
|
22938
|
+
return undefined;
|
|
22939
|
+
}
|
|
22926
22940
|
|
|
22927
|
-
|
|
22928
|
-
|
|
22941
|
+
debugUIState(`"${controlType}" registering into "${parent.controlType}"`);
|
|
22942
|
+
parentController.registerChild(controller);
|
|
22943
|
+
return () => {
|
|
22929
22944
|
debugUIState(
|
|
22930
|
-
`"${controlType}" unregistering from "${
|
|
22945
|
+
`"${controlType}" unregistering from "${parent.controlType}"`,
|
|
22931
22946
|
);
|
|
22932
|
-
|
|
22947
|
+
parentController.unregisterChild(controller);
|
|
22933
22948
|
};
|
|
22949
|
+
}, [parentController]);
|
|
22934
22950
|
|
|
22935
|
-
|
|
22936
|
-
notifyParentAboutChildMount,
|
|
22937
|
-
notifyParentAboutChildUIAction,
|
|
22938
|
-
notifyParentAboutChildUnmount,
|
|
22939
|
-
];
|
|
22940
|
-
}, []);
|
|
22951
|
+
return controller;
|
|
22941
22952
|
};
|
|
22942
22953
|
|
|
22943
22954
|
/**
|
|
@@ -23092,9 +23103,8 @@ const useUIGroupStateController = (
|
|
|
23092
23103
|
const parentUIStateController = useContext(ParentUIStateControllerContext);
|
|
23093
23104
|
const hasValueProp = Object.hasOwn(props, "value");
|
|
23094
23105
|
const hasDefaultValueProp = Object.hasOwn(props, "defaultValue");
|
|
23095
|
-
const { id, name, value, defaultValue, uiAction
|
|
23106
|
+
const { id, name, value, defaultValue, uiAction } = props;
|
|
23096
23107
|
const ref = props.ref;
|
|
23097
|
-
const uiActionRef = useRef(uiAction);
|
|
23098
23108
|
const fallbackState =
|
|
23099
23109
|
stateType === "array"
|
|
23100
23110
|
? EMPTY_ARRAY
|
|
@@ -23103,9 +23113,7 @@ const useUIGroupStateController = (
|
|
|
23103
23113
|
: undefined;
|
|
23104
23114
|
const childUIStateControllerArrayRef = useRef([]);
|
|
23105
23115
|
const childUIStateControllerArray = childUIStateControllerArrayRef.current;
|
|
23106
|
-
|
|
23107
|
-
// Tracks children this controller rejected and delegated upward (bubble-up
|
|
23108
|
-
// registration). Used to forward onChildUIAction and unregisterChild.
|
|
23116
|
+
// Tracks children rejected by the filter and delegated upward (bubble-up).
|
|
23109
23117
|
const delegatedChildrenRef = useRef(new Map());
|
|
23110
23118
|
|
|
23111
23119
|
const groupIsRenderingRef = useRef(false);
|
|
@@ -23113,103 +23121,8 @@ const useUIGroupStateController = (
|
|
|
23113
23121
|
groupIsRenderingRef.current = true;
|
|
23114
23122
|
pendingChangeRef.current = false;
|
|
23115
23123
|
|
|
23116
|
-
const [
|
|
23117
|
-
notifyParentAboutChildMount,
|
|
23118
|
-
notifyParentAboutChildUIAction,
|
|
23119
|
-
notifyParentAboutChildUnmount,
|
|
23120
|
-
] = useParentControllerNotifiers(
|
|
23121
|
-
parentUIStateController,
|
|
23122
|
-
controllerRef,
|
|
23123
|
-
controlType,
|
|
23124
|
-
debugUIGroup,
|
|
23125
|
-
);
|
|
23126
|
-
useLayoutEffect(() => {
|
|
23127
|
-
const controller = controllerRef.current;
|
|
23128
|
-
const el = ref.current;
|
|
23129
|
-
if (el) {
|
|
23130
|
-
el.__uiStateController__ = controller;
|
|
23131
|
-
}
|
|
23132
|
-
notifyParentAboutChildMount();
|
|
23133
|
-
return () => {
|
|
23134
|
-
notifyParentAboutChildUnmount();
|
|
23135
|
-
onUIStateControllerDestroyed(controller);
|
|
23136
|
-
};
|
|
23137
|
-
}, []);
|
|
23138
|
-
|
|
23139
|
-
const onChange = (e, { notifyExternal }) => {
|
|
23140
|
-
if (groupIsRenderingRef.current) {
|
|
23141
|
-
pendingChangeRef.current = true;
|
|
23142
|
-
return;
|
|
23143
|
-
}
|
|
23144
|
-
const aggChildState = resolvedAggregateChildStates(
|
|
23145
|
-
childUIStateControllerArray,
|
|
23146
|
-
fallbackState,
|
|
23147
|
-
);
|
|
23148
|
-
const groupUIState =
|
|
23149
|
-
aggChildState === undefined ? fallbackState : aggChildState;
|
|
23150
|
-
debugUIGroup(
|
|
23151
|
-
e,
|
|
23152
|
-
`${controlType}.getUIState -> ${JSON.stringify(groupUIState)}`,
|
|
23153
|
-
);
|
|
23154
|
-
const groupUIStateController = controllerRef.current;
|
|
23155
|
-
if (notifyExternal === true) {
|
|
23156
|
-
applyState(groupUIState, e);
|
|
23157
|
-
} else if (notifyExternal === "silent") {
|
|
23158
|
-
// Silent mount/unmount sync: update state without triggering uiAction/command,
|
|
23159
|
-
// but still notify parent (e.g. facade) so it can track the current child state.
|
|
23160
|
-
groupUIStateController.syncInternalState(groupUIState, e);
|
|
23161
|
-
notifyParentAboutChildUIAction(e, {
|
|
23162
|
-
stateChanged: true,
|
|
23163
|
-
silent: true,
|
|
23164
|
-
});
|
|
23165
|
-
} else {
|
|
23166
|
-
groupUIStateController.syncInternalState(groupUIState, e);
|
|
23167
|
-
}
|
|
23168
|
-
};
|
|
23169
|
-
|
|
23170
|
-
// Applies the aggregated state: updates signal, fires uiAction/command/navi_ui_state_change,
|
|
23171
|
-
// and notifies the parent. Called both from onChange (after child UI action) and from
|
|
23172
|
-
// setUIState (after cascading to children).
|
|
23173
|
-
const applyState = (newUIState, e, { internalBehavior = false } = {}) => {
|
|
23174
|
-
const groupUIStateController = controllerRef.current;
|
|
23175
|
-
const currentUIState = groupUIStateController.uiState;
|
|
23176
|
-
groupUIStateController.uiState = newUIState;
|
|
23177
|
-
uiStateSignal.value = newUIState;
|
|
23178
|
-
debugUIGroup(
|
|
23179
|
-
e,
|
|
23180
|
-
`${controlType}.applyState(${JSON.stringify(newUIState)}, "${e.type}") -> updates from ${JSON.stringify(currentUIState)} to ${JSON.stringify(newUIState)}`,
|
|
23181
|
-
);
|
|
23182
|
-
publishUIState(newUIState);
|
|
23183
|
-
// Notify the parent (facade) BEFORE firing the command so that when a
|
|
23184
|
-
// command like --navi-send closes the picker, the picker input already
|
|
23185
|
-
// holds the new value.
|
|
23186
|
-
notifyParentAboutChildUIAction(e, { stateChanged: true });
|
|
23187
|
-
groupUIStateController.onUIAction(e, {
|
|
23188
|
-
skipCommand: internalBehavior,
|
|
23189
|
-
});
|
|
23190
|
-
const el = ref.current;
|
|
23191
|
-
if (el) {
|
|
23192
|
-
dispatchInternalCustomEvent(el, "navi_ui_state_change", {
|
|
23193
|
-
event: e,
|
|
23194
|
-
value: newUIState,
|
|
23195
|
-
});
|
|
23196
|
-
}
|
|
23197
|
-
};
|
|
23198
|
-
|
|
23199
|
-
useLayoutEffect(() => {
|
|
23200
|
-
groupIsRenderingRef.current = false;
|
|
23201
|
-
if (pendingChangeRef.current) {
|
|
23202
|
-
pendingChangeRef.current = false;
|
|
23203
|
-
onChange(new CustomEvent(`${controlType}_batched_ui_state_update`), {
|
|
23204
|
-
notifyExternal: "silent",
|
|
23205
|
-
});
|
|
23206
|
-
}
|
|
23207
|
-
});
|
|
23208
|
-
|
|
23209
23124
|
const isMonitoringChild = (childUIStateController) => {
|
|
23210
|
-
if (childUIStateController.isProxy)
|
|
23211
|
-
return false;
|
|
23212
|
-
}
|
|
23125
|
+
if (childUIStateController.isProxy) return false;
|
|
23213
23126
|
if (
|
|
23214
23127
|
resolvedChildControlFilter &&
|
|
23215
23128
|
!resolvedChildControlFilter(childUIStateController)
|
|
@@ -23219,354 +23132,411 @@ const useUIGroupStateController = (
|
|
|
23219
23132
|
return true;
|
|
23220
23133
|
};
|
|
23221
23134
|
const shouldPropagateStateToChild = (childUIStateController) => {
|
|
23222
|
-
if (!isMonitoringChild(childUIStateController))
|
|
23223
|
-
|
|
23224
|
-
|
|
23225
|
-
if (childUIStateController.controlType === "button") {
|
|
23226
|
-
return false;
|
|
23227
|
-
}
|
|
23228
|
-
if (childUIStateController.controlType === "link") {
|
|
23229
|
-
return false;
|
|
23230
|
-
}
|
|
23135
|
+
if (!isMonitoringChild(childUIStateController)) return false;
|
|
23136
|
+
if (childUIStateController.controlType === "button") return false;
|
|
23137
|
+
if (childUIStateController.controlType === "link") return false;
|
|
23231
23138
|
return true;
|
|
23232
23139
|
};
|
|
23233
23140
|
|
|
23234
|
-
const
|
|
23235
|
-
|
|
23236
|
-
|
|
23237
|
-
|
|
23238
|
-
|
|
23239
|
-
// Re-sync to this render's ref object — see the matching comment in
|
|
23240
|
-
// useUIStateController._checkForUpdates for why this can't be captured
|
|
23241
|
-
// once at creation time and left untouched.
|
|
23242
|
-
existingController.ref = ref;
|
|
23243
|
-
existingController.id = id;
|
|
23244
|
-
existingController.name = name;
|
|
23245
|
-
existingController.value = value;
|
|
23246
|
-
existingController.defaultValue = defaultValue;
|
|
23247
|
-
existingController.hasValueProp = hasValueProp;
|
|
23248
|
-
existingController.hasDefaultValueProp = hasDefaultValueProp;
|
|
23249
|
-
uiActionRef.current = uiAction;
|
|
23250
|
-
// When the controlled value prop changes (or when becoming controlled for the
|
|
23251
|
-
// first time), silently cascade to children that have no individual state prop.
|
|
23252
|
-
if (
|
|
23253
|
-
hasValueProp &&
|
|
23254
|
-
(!prevHasValueProp || !compareTwoJsValues(value, prevValue))
|
|
23255
|
-
) {
|
|
23256
|
-
const propagateDownEvent = new CustomEvent(
|
|
23257
|
-
"propagate_down_set_ui_state",
|
|
23258
|
-
{ 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))"`,
|
|
23259
23146
|
);
|
|
23260
|
-
|
|
23261
|
-
|
|
23262
|
-
|
|
23263
|
-
|
|
23264
|
-
|
|
23265
|
-
|
|
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;
|
|
23266
23156
|
}
|
|
23267
|
-
const
|
|
23268
|
-
|
|
23269
|
-
|
|
23157
|
+
const aggChildState = resolvedAggregateChildStates(
|
|
23158
|
+
childUIStateControllerArray,
|
|
23159
|
+
fallbackState,
|
|
23270
23160
|
);
|
|
23271
|
-
|
|
23272
|
-
|
|
23273
|
-
|
|
23274
|
-
|
|
23275
|
-
|
|
23276
|
-
existingController.syncInternalState(value);
|
|
23277
|
-
}
|
|
23278
|
-
return existingController;
|
|
23279
|
-
}
|
|
23280
|
-
debugUIGroup(
|
|
23281
|
-
`Creating "${controlType}" ui state controller (monitoring some descendants ui state(s))"`,
|
|
23282
|
-
);
|
|
23283
|
-
|
|
23284
|
-
const [publishUIState, subscribeUIState] = createPubSub();
|
|
23285
|
-
const uiStateSignal = signal(fallbackState);
|
|
23286
|
-
const groupUIStateController = {
|
|
23287
|
-
controlType,
|
|
23288
|
-
id,
|
|
23289
|
-
name,
|
|
23290
|
-
value,
|
|
23291
|
-
defaultValue,
|
|
23292
|
-
hasValueProp,
|
|
23293
|
-
hasDefaultValueProp,
|
|
23294
|
-
props,
|
|
23295
|
-
uiState: fallbackState,
|
|
23296
|
-
uiStateSignal,
|
|
23297
|
-
wantRequesterButtonState,
|
|
23298
|
-
ref,
|
|
23299
|
-
getPropFromState: (uiState) => uiState,
|
|
23300
|
-
distributeChildUIState: resolvedDistributeChildUIState,
|
|
23301
|
-
// Cascades newUIState to each monitored child via resolvedDistributeChildUIState,
|
|
23302
|
-
// then re-aggregates and fires this group's own reactions.
|
|
23303
|
-
setUIState: (newUIState, e) => {
|
|
23304
|
-
if (
|
|
23305
|
-
stateType === "object" &&
|
|
23306
|
-
(newUIState === null || typeof newUIState !== "object")
|
|
23307
|
-
) {
|
|
23308
|
-
console.warn(
|
|
23309
|
-
`[${controlType}] setUIState received a non-object value: ${JSON.stringify(newUIState)} (expected an object). Ignoring.`,
|
|
23310
|
-
newUIState,
|
|
23311
|
-
);
|
|
23312
|
-
return;
|
|
23313
|
-
}
|
|
23314
|
-
if (stateType === "array" && !Array.isArray(newUIState)) {
|
|
23315
|
-
console.warn(
|
|
23316
|
-
`[${controlType}] setUIState received a non-array value: ${JSON.stringify(newUIState)} (expected an array). Ignoring.`,
|
|
23317
|
-
newUIState,
|
|
23161
|
+
const groupUIState =
|
|
23162
|
+
aggChildState === undefined ? fallbackState : aggChildState;
|
|
23163
|
+
debugUIGroup(
|
|
23164
|
+
e,
|
|
23165
|
+
`${controlType}.getUIState -> ${JSON.stringify(groupUIState)}`,
|
|
23318
23166
|
);
|
|
23319
|
-
|
|
23320
|
-
|
|
23321
|
-
|
|
23322
|
-
|
|
23323
|
-
|
|
23324
|
-
|
|
23325
|
-
|
|
23326
|
-
|
|
23327
|
-
|
|
23328
|
-
|
|
23329
|
-
|
|
23330
|
-
chainEvent(propagateDownEvent, e);
|
|
23331
|
-
for (const childUIStateController of childUIStateControllerArray) {
|
|
23332
|
-
if (!shouldPropagateStateToChild(childUIStateController)) {
|
|
23333
|
-
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);
|
|
23334
23178
|
}
|
|
23335
|
-
|
|
23336
|
-
|
|
23337
|
-
|
|
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)}`,
|
|
23338
23189
|
);
|
|
23339
|
-
|
|
23340
|
-
|
|
23341
|
-
|
|
23342
|
-
|
|
23343
|
-
|
|
23344
|
-
|
|
23345
|
-
// (onChange would call setUIState again, which would cascade again).
|
|
23346
|
-
const aggChildState = resolvedAggregateChildStates(
|
|
23347
|
-
childUIStateControllerArray,
|
|
23348
|
-
fallbackState,
|
|
23349
|
-
);
|
|
23350
|
-
const groupUIState =
|
|
23351
|
-
aggChildState === undefined ? fallbackState : aggChildState;
|
|
23352
|
-
if (e.type === "initial_state_push") {
|
|
23353
|
-
// Silent initialization: update state without firing uiAction or notifying parent.
|
|
23354
|
-
groupUIStateController.syncInternalState(groupUIState);
|
|
23355
|
-
return;
|
|
23356
|
-
}
|
|
23357
|
-
applyState(groupUIState, e, { internalBehavior: true });
|
|
23358
|
-
},
|
|
23359
|
-
// Called on mount/unmount/render-batch: updates state silently with no external reactions.
|
|
23360
|
-
syncInternalState: (newUIState) => {
|
|
23361
|
-
const currentUIState = groupUIStateController.uiState;
|
|
23362
|
-
if (newUIState === currentUIState) {
|
|
23363
|
-
return;
|
|
23364
|
-
}
|
|
23365
|
-
groupUIStateController.uiState = newUIState;
|
|
23366
|
-
uiStateSignal.value = newUIState;
|
|
23367
|
-
publishUIState(newUIState);
|
|
23368
|
-
},
|
|
23369
|
-
// Called when a child UI action does NOT change the aggregated value (e.g. radio re-clicked).
|
|
23370
|
-
// Fires uiAction + command without touching state or the action pipeline.
|
|
23371
|
-
onUIAction: (e, { skipCommand } = {}) => {
|
|
23372
|
-
const currentUIState = groupUIStateController.uiState;
|
|
23373
|
-
const uiAction = uiActionRef.current;
|
|
23374
|
-
uiAction?.(currentUIState, e);
|
|
23375
|
-
uiActionInternal?.(currentUIState, e);
|
|
23376
|
-
if (skipCommand) ; else if (command) {
|
|
23377
|
-
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;
|
|
23378
23196
|
if (el) {
|
|
23379
|
-
|
|
23197
|
+
dispatchInternalCustomEvent(el, "navi_ui_state_change", {
|
|
23198
|
+
event: e,
|
|
23199
|
+
value: newUIState,
|
|
23200
|
+
});
|
|
23380
23201
|
}
|
|
23381
|
-
}
|
|
23382
|
-
|
|
23383
|
-
|
|
23384
|
-
|
|
23385
|
-
|
|
23386
|
-
|
|
23387
|
-
|
|
23388
|
-
|
|
23389
|
-
|
|
23390
|
-
|
|
23391
|
-
|
|
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(
|
|
23392
23334
|
childUIStateController,
|
|
23393
|
-
parentUIStateController,
|
|
23394
23335
|
);
|
|
23395
|
-
|
|
23396
|
-
|
|
23397
|
-
|
|
23398
|
-
|
|
23399
|
-
|
|
23400
|
-
|
|
23401
|
-
|
|
23402
|
-
|
|
23403
|
-
|
|
23404
|
-
|
|
23405
|
-
|
|
23406
|
-
|
|
23407
|
-
|
|
23408
|
-
|
|
23409
|
-
|
|
23410
|
-
|
|
23411
|
-
|
|
23412
|
-
|
|
23413
|
-
|
|
23414
|
-
|
|
23415
|
-
|
|
23416
|
-
|
|
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(
|
|
23417
23358
|
childUIStateController,
|
|
23418
23359
|
);
|
|
23419
|
-
if (
|
|
23420
|
-
|
|
23360
|
+
if (delegatedTo) {
|
|
23361
|
+
delegatedChildrenRef.current.delete(childUIStateController);
|
|
23362
|
+
delegatedTo.unregisterChild(childUIStateController);
|
|
23363
|
+
return;
|
|
23421
23364
|
}
|
|
23422
|
-
|
|
23423
|
-
|
|
23424
|
-
const
|
|
23425
|
-
groupUIStateController.defaultValue,
|
|
23365
|
+
if (!isMonitoringChild(childUIStateController)) return;
|
|
23366
|
+
const childControlType = childUIStateController.controlType;
|
|
23367
|
+
const index = childUIStateControllerArray.indexOf(
|
|
23426
23368
|
childUIStateController,
|
|
23427
23369
|
);
|
|
23428
|
-
if (
|
|
23429
|
-
|
|
23370
|
+
if (index === -1) {
|
|
23371
|
+
debugUIGroup(
|
|
23372
|
+
`${controlType}.unregisterChild("${childControlType}") -> not found`,
|
|
23373
|
+
);
|
|
23374
|
+
return;
|
|
23430
23375
|
}
|
|
23431
|
-
|
|
23432
|
-
|
|
23433
|
-
|
|
23434
|
-
|
|
23435
|
-
|
|
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,
|
|
23436
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
|
+
};
|
|
23437
23448
|
},
|
|
23438
|
-
|
|
23439
|
-
|
|
23440
|
-
|
|
23441
|
-
|
|
23442
|
-
|
|
23443
|
-
|
|
23444
|
-
|
|
23445
|
-
|
|
23446
|
-
|
|
23447
|
-
|
|
23448
|
-
|
|
23449
|
-
|
|
23450
|
-
|
|
23451
|
-
if (
|
|
23452
|
-
|
|
23453
|
-
|
|
23454
|
-
|
|
23455
|
-
|
|
23456
|
-
|
|
23457
|
-
|
|
23458
|
-
)
|
|
23459
|
-
|
|
23460
|
-
|
|
23461
|
-
|
|
23462
|
-
|
|
23463
|
-
|
|
23464
|
-
|
|
23465
|
-
|
|
23466
|
-
|
|
23467
|
-
|
|
23468
|
-
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);
|
|
23469
23479
|
}
|
|
23470
|
-
|
|
23471
|
-
// Value unchanged (e.g. radio re-clicked): fire uiAction + command only.
|
|
23472
|
-
groupUIStateController.onUIAction(e);
|
|
23480
|
+
controller.syncInternalState(value);
|
|
23473
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
|
+
};
|
|
23474
23496
|
},
|
|
23475
|
-
|
|
23476
|
-
|
|
23477
|
-
|
|
23478
|
-
|
|
23479
|
-
|
|
23480
|
-
|
|
23481
|
-
|
|
23482
|
-
|
|
23483
|
-
|
|
23484
|
-
|
|
23485
|
-
|
|
23486
|
-
|
|
23487
|
-
|
|
23488
|
-
|
|
23489
|
-
|
|
23490
|
-
|
|
23491
|
-
|
|
23492
|
-
|
|
23493
|
-
|
|
23494
|
-
|
|
23495
|
-
|
|
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 () => {
|
|
23496
23521
|
debugUIGroup(
|
|
23497
|
-
|
|
23498
|
-
);
|
|
23499
|
-
onChange(new CustomEvent(`${childControlType}_unmount`), {
|
|
23500
|
-
notifyExternal: "silent",
|
|
23501
|
-
// childUIStateController,
|
|
23502
|
-
});
|
|
23503
|
-
},
|
|
23504
|
-
resetUIState: (e) => {
|
|
23505
|
-
const propagateDownResetEvent = new CustomEvent(
|
|
23506
|
-
"propagate_down_reset_ui_state",
|
|
23507
|
-
{ detail: {} },
|
|
23522
|
+
`"${controlType}" unregistering from "${parentController.controlType}"`,
|
|
23508
23523
|
);
|
|
23509
|
-
|
|
23510
|
-
|
|
23511
|
-
|
|
23512
|
-
|
|
23513
|
-
|
|
23514
|
-
|
|
23515
|
-
|
|
23516
|
-
|
|
23517
|
-
|
|
23518
|
-
|
|
23519
|
-
|
|
23520
|
-
"propagate_down_clear_ui_state",
|
|
23521
|
-
{ 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" },
|
|
23522
23535
|
);
|
|
23523
|
-
|
|
23524
|
-
for (const childUIStateController of childUIStateControllerArray) {
|
|
23525
|
-
if (!isMonitoringChild(childUIStateController)) {
|
|
23526
|
-
continue;
|
|
23527
|
-
}
|
|
23528
|
-
if (childUIStateController.controlType === "button") {
|
|
23529
|
-
continue;
|
|
23530
|
-
}
|
|
23531
|
-
if (childUIStateController.controlType === "link") {
|
|
23532
|
-
continue;
|
|
23533
|
-
}
|
|
23534
|
-
childUIStateController.clearUIState(propagateDownClearEvent);
|
|
23535
|
-
}
|
|
23536
|
-
onChange(e, { notifyExternal: true });
|
|
23537
|
-
},
|
|
23538
|
-
onActionEnd: (e) => {
|
|
23539
|
-
groupUIStateController.rules.validation.syncValidity(e);
|
|
23540
|
-
},
|
|
23541
|
-
onActionError: (e) => {
|
|
23542
|
-
groupUIStateController.rules.validation.syncValidity(e, { report: true });
|
|
23543
|
-
},
|
|
23544
|
-
findChildById: (id) => {
|
|
23545
|
-
for (const childUIStateController of childUIStateControllerArray) {
|
|
23546
|
-
if (childUIStateController.id === id) {
|
|
23547
|
-
return childUIStateController;
|
|
23548
|
-
}
|
|
23549
|
-
}
|
|
23550
|
-
return null;
|
|
23551
|
-
},
|
|
23552
|
-
getChildControllers: () => childUIStateControllerArray,
|
|
23553
|
-
getManagedControls: () => {
|
|
23554
|
-
if (!cascadeValidationToChildren) {
|
|
23555
|
-
return [];
|
|
23556
|
-
}
|
|
23557
|
-
return childUIStateControllerArray.slice();
|
|
23558
|
-
},
|
|
23559
|
-
subscribe: subscribeUIState,
|
|
23560
|
-
};
|
|
23561
|
-
controllerRef.current = groupUIStateController;
|
|
23562
|
-
const rules = createControlRules(groupUIStateController, {
|
|
23563
|
-
debugPopup,
|
|
23564
|
-
debugInteraction,
|
|
23565
|
-
debugUIState: debugUIGroup,
|
|
23566
|
-
debugFocus,
|
|
23536
|
+
}
|
|
23567
23537
|
});
|
|
23568
|
-
|
|
23569
|
-
return
|
|
23538
|
+
|
|
23539
|
+
return controller;
|
|
23570
23540
|
};
|
|
23571
23541
|
// Stable reference for an empty selection so the action always receives an
|
|
23572
23542
|
// array (never undefined) and callers don't get a new reference each render.
|
|
@@ -23605,6 +23575,141 @@ const useUIFacadeStateController = (props, realUIStateController) => {
|
|
|
23605
23575
|
const debugUIState = useDebugUIState();
|
|
23606
23576
|
const debugFocus = useDebugFocus();
|
|
23607
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
|
+
|
|
23608
23713
|
useLayoutEffect(() => {
|
|
23609
23714
|
return realUIStateController.subscribe((newUIState, e) => {
|
|
23610
23715
|
if (updatingRef.current) {
|
|
@@ -23623,132 +23728,9 @@ const useUIFacadeStateController = (props, realUIStateController) => {
|
|
|
23623
23728
|
child.setUIState(newUIState, propagateDownEvent);
|
|
23624
23729
|
updatingRef.current = false;
|
|
23625
23730
|
});
|
|
23626
|
-
}, []);
|
|
23627
|
-
|
|
23628
|
-
const canRegisterAsFacadeChild = (childController) => {
|
|
23629
|
-
if (childController.controlType === "button") {
|
|
23630
|
-
return false;
|
|
23631
|
-
}
|
|
23632
|
-
if (childController.controlType === "link") {
|
|
23633
|
-
return false;
|
|
23634
|
-
}
|
|
23635
|
-
if (childController.controlType === "facade") {
|
|
23636
|
-
return false;
|
|
23637
|
-
}
|
|
23638
|
-
if (childController.isProxy) {
|
|
23639
|
-
return false;
|
|
23640
|
-
}
|
|
23641
|
-
if (childController.props["navi-list"]) {
|
|
23642
|
-
// Controls with navi-list act as standalone list navigators and should
|
|
23643
|
-
// not be treated as the picker's synced child.
|
|
23644
|
-
return false;
|
|
23645
|
-
}
|
|
23646
|
-
if (
|
|
23647
|
-
props.type === "controlgroup" &&
|
|
23648
|
-
childController.controlType !== "control_group"
|
|
23649
|
-
) {
|
|
23650
|
-
// ignore non control group registration (input outside the control group for instance)
|
|
23651
|
-
return false;
|
|
23652
|
-
}
|
|
23653
|
-
if (
|
|
23654
|
-
props.type === "array" &&
|
|
23655
|
-
childController.controlType !== "checkbox_group"
|
|
23656
|
-
) {
|
|
23657
|
-
// only selectable list expose array, ignore others
|
|
23658
|
-
return false;
|
|
23659
|
-
}
|
|
23660
|
-
return true;
|
|
23661
|
-
};
|
|
23731
|
+
}, [realUIStateController]);
|
|
23662
23732
|
|
|
23663
|
-
|
|
23664
|
-
if (controllerRef.current) {
|
|
23665
|
-
return controllerRef.current;
|
|
23666
|
-
}
|
|
23667
|
-
|
|
23668
|
-
const facadeUIStateController = {
|
|
23669
|
-
controlType: "facade",
|
|
23670
|
-
props,
|
|
23671
|
-
ref: realUIStateController.ref,
|
|
23672
|
-
uiStateSignal: realUIStateController.uiStateSignal,
|
|
23673
|
-
registerChild: (child) => {
|
|
23674
|
-
if (!canRegisterAsFacadeChild(child)) {
|
|
23675
|
-
return;
|
|
23676
|
-
}
|
|
23677
|
-
const childType = child.controlType;
|
|
23678
|
-
if (firstChildControllerRef.current) {
|
|
23679
|
-
console.warn(
|
|
23680
|
-
`[useUIFacadeStateController] A second child ("${childType}"${child.name ? ` name="${child.name}"` : ""}) tried to register in the picker facade. ` +
|
|
23681
|
-
`The facade only syncs with the first child — wrap multiple controls in a single ControlGroup.`,
|
|
23682
|
-
child,
|
|
23683
|
-
);
|
|
23684
|
-
} else {
|
|
23685
|
-
debugUIState(
|
|
23686
|
-
`[useUIFacadeStateController] "${childType}"${child.name ? ` name="${child.name}"` : ""} registered as the first child in the picker facade.`,
|
|
23687
|
-
);
|
|
23688
|
-
firstChildControllerRef.current = child;
|
|
23689
|
-
realUIStateController.facadeChild = child;
|
|
23690
|
-
// If the picker already has a meaningful state (from value or defaultValue),
|
|
23691
|
-
// push it to the child on registration so it reflects the pre-set value
|
|
23692
|
-
// without firing uiAction (equivalent to defaultValue on the child itself).
|
|
23693
|
-
const initialState = realUIStateController.uiState;
|
|
23694
|
-
if (initialState !== undefined) {
|
|
23695
|
-
updatingRef.current = true;
|
|
23696
|
-
const initialEvent = new CustomEvent("initial_state_push", {
|
|
23697
|
-
detail: {},
|
|
23698
|
-
});
|
|
23699
|
-
child.setUIState(initialState, initialEvent);
|
|
23700
|
-
updatingRef.current = false;
|
|
23701
|
-
}
|
|
23702
|
-
}
|
|
23703
|
-
},
|
|
23704
|
-
unregisterChild: (child) => {
|
|
23705
|
-
if (firstChildControllerRef.current === child) {
|
|
23706
|
-
firstChildControllerRef.current = null;
|
|
23707
|
-
realUIStateController.facadeChild = null;
|
|
23708
|
-
}
|
|
23709
|
-
},
|
|
23710
|
-
getManagedControls: () => {
|
|
23711
|
-
const child = firstChildControllerRef.current;
|
|
23712
|
-
if (!child) {
|
|
23713
|
-
return [];
|
|
23714
|
-
}
|
|
23715
|
-
return child.getManagedControls();
|
|
23716
|
-
},
|
|
23717
|
-
onChildUIAction: (child, e, { stateChanged, silent = false }) => {
|
|
23718
|
-
if (!stateChanged) {
|
|
23719
|
-
return;
|
|
23720
|
-
}
|
|
23721
|
-
if (child !== firstChildControllerRef.current) {
|
|
23722
|
-
return;
|
|
23723
|
-
}
|
|
23724
|
-
updatingRef.current = true;
|
|
23725
|
-
// Use a different event type for silent (mount/unmount) syncs so that
|
|
23726
|
-
// the picker's setUIState does not fire navi_change or action pipelines.
|
|
23727
|
-
const eventType = silent
|
|
23728
|
-
? "facade_child_mount_sync"
|
|
23729
|
-
: "facade_propagate_up";
|
|
23730
|
-
const propagateUpEvent = new CustomEvent(eventType, {
|
|
23731
|
-
detail: {},
|
|
23732
|
-
});
|
|
23733
|
-
chainEvent(propagateUpEvent, e);
|
|
23734
|
-
realUIStateController.setUIState(child.uiState, propagateUpEvent);
|
|
23735
|
-
updatingRef.current = false;
|
|
23736
|
-
},
|
|
23737
|
-
};
|
|
23738
|
-
controllerRef.current = facadeUIStateController;
|
|
23739
|
-
const rules = createControlRules(facadeUIStateController, {
|
|
23740
|
-
debugPopup,
|
|
23741
|
-
debugInteraction,
|
|
23742
|
-
debugUIState,
|
|
23743
|
-
debugFocus,
|
|
23744
|
-
});
|
|
23745
|
-
facadeUIStateController.rules = rules;
|
|
23746
|
-
facadeUIStateController.controlHostProps =
|
|
23747
|
-
realUIStateController.controlHostProps;
|
|
23748
|
-
// No initial checkValidity() here — the facade has no controlHostProps and no children
|
|
23749
|
-
// have registered yet, so any check would be a no-op. The real validity check happens
|
|
23750
|
-
// when child controllers trigger UI actions through the facade.
|
|
23751
|
-
return facadeUIStateController;
|
|
23733
|
+
return scope.controller;
|
|
23752
23734
|
};
|
|
23753
23735
|
|
|
23754
23736
|
/**
|
|
@@ -24410,7 +24392,7 @@ const useControlProps = (props, {
|
|
|
24410
24392
|
debounce: actionDebounce,
|
|
24411
24393
|
debugInteraction
|
|
24412
24394
|
});
|
|
24413
|
-
}, [actionEvent, actionAfterChange, actionDebounce, hasNaviChangeEventReaction]);
|
|
24395
|
+
}, [actionEvent, actionAfterChange, actionDebounce, hasNaviChangeEventReaction, ref]);
|
|
24414
24396
|
const refComposed = useComposeElementRef(refCallback, ref);
|
|
24415
24397
|
const onPaste = e => {
|
|
24416
24398
|
props.onPaste?.(e);
|
|
@@ -33960,20 +33942,24 @@ const InputTextualFirstResolver = props => {
|
|
|
33960
33942
|
});
|
|
33961
33943
|
};
|
|
33962
33944
|
const InputTextual = createComponentResolver([InputTextualFirstResolver, InputWithListResolver, InputWithSuggestionsResolver, InputTypeResolver, InputModeResolver, InputHeadlessResolver, InputTextualUI]);
|
|
33963
|
-
const RealInput =
|
|
33964
|
-
|
|
33945
|
+
const RealInput = ({
|
|
33946
|
+
maxLength,
|
|
33947
|
+
...domProps
|
|
33948
|
+
}) => {
|
|
33949
|
+
const autoSelectReadOnlyProps = useAutoSelectReadOnly(domProps);
|
|
33965
33950
|
return jsx(Box, {
|
|
33966
|
-
...
|
|
33951
|
+
...domProps,
|
|
33967
33952
|
as: "input",
|
|
33968
33953
|
baseClassName: "navi_control_input",
|
|
33969
33954
|
...autoSelectReadOnlyProps,
|
|
33970
|
-
// Never set native maxLength — our guard handles it.
|
|
33971
|
-
//
|
|
33972
|
-
maxLength
|
|
33973
|
-
//
|
|
33974
|
-
|
|
33975
|
-
|
|
33976
|
-
|
|
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
|
|
33977
33963
|
});
|
|
33978
33964
|
};
|
|
33979
33965
|
const InputStyleCSSVars = {
|
|
@@ -36225,6 +36211,11 @@ const PickerCustom = props => {
|
|
|
36225
36211
|
}
|
|
36226
36212
|
const pickerEl = ref.current;
|
|
36227
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
|
+
}
|
|
36228
36219
|
dispatchRequestAction(inputEl, {
|
|
36229
36220
|
event: requestCloseEvent,
|
|
36230
36221
|
name: "picker request close",
|