@jsenv/navi 0.29.42 → 0.29.44
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 +655 -250
- package/dist/jsenv_navi.js.map +51 -28
- package/dist/jsenv_navi_side_effects.js +5 -0
- package/dist/jsenv_navi_side_effects.js.map +2 -2
- package/docs/AI_INSTRUCTIONS.md +15 -2
- package/docs/control_group.md +5 -0
- package/docs/control_object.md +133 -0
- package/docs/control_value.md +21 -0
- package/docs/form_changed.md +129 -0
- package/docs/list_refresh.md +61 -12
- package/docs/scroll.md +54 -4
- package/docs/z_index.md +41 -0
- package/package.json +1 -1
package/dist/jsenv_navi.js
CHANGED
|
@@ -51,7 +51,15 @@ const css$10 = /* css */`
|
|
|
51
51
|
/* Kept stuck while something scrolls under it: a list header, the head
|
|
52
52
|
and foot of a side panel, a table's sticky cells. Above raised
|
|
53
53
|
controls — a control scrolling past must go under the header that
|
|
54
|
-
pins the column it belongs to, never over it.
|
|
54
|
+
pins the column it belongs to, never over it.
|
|
55
|
+
|
|
56
|
+
"While stuck" is the whole condition, and a sticky element cannot read
|
|
57
|
+
its own stuck state in CSS: List marks its parts with a navi-stuck
|
|
58
|
+
attribute and applies this band only there (see --list-*-z-index in
|
|
59
|
+
list.jsx). A
|
|
60
|
+
sticky part at rest is a block in the flow with nothing passing under
|
|
61
|
+
it; giving it this band anyway is what slices whatever a neighbouring
|
|
62
|
+
row lets out of its box. */
|
|
55
63
|
--navi-z-index-sticky: 10;
|
|
56
64
|
|
|
57
65
|
/* Pinned to the viewport, over the whole page: FixedBar. A decade of its
|
|
@@ -22983,6 +22991,9 @@ const useUIStateController = (
|
|
|
22983
22991
|
// resolveCommandValue in commands.js.
|
|
22984
22992
|
ownUIStateSignal,
|
|
22985
22993
|
value: controlInfo.value,
|
|
22994
|
+
// The suggestion this control started on — what tells a field showing
|
|
22995
|
+
// its default from one carrying an answer (see isUIStateHeld).
|
|
22996
|
+
defaultValue: controlInfo.defaultValue,
|
|
22986
22997
|
|
|
22987
22998
|
facadeChild: null,
|
|
22988
22999
|
getManagedControls: () => {
|
|
@@ -23401,9 +23412,16 @@ const useUIStateController = (
|
|
|
23401
23412
|
controller.id = props.id; // never supposed to change, not supported for now
|
|
23402
23413
|
controller.name = props.name;
|
|
23403
23414
|
controller.parentUIStateController = parentUIStateController;
|
|
23404
|
-
const {
|
|
23405
|
-
|
|
23415
|
+
const {
|
|
23416
|
+
value,
|
|
23417
|
+
defaultValue,
|
|
23418
|
+
hasStateProp,
|
|
23419
|
+
state,
|
|
23420
|
+
stateInitial,
|
|
23421
|
+
stateFromSignal,
|
|
23422
|
+
} = controlInfo;
|
|
23406
23423
|
controller.value = value;
|
|
23424
|
+
controller.defaultValue = defaultValue;
|
|
23407
23425
|
if (hasStateProp) {
|
|
23408
23426
|
controller.hasStateProp = true;
|
|
23409
23427
|
const currentState = controller.state;
|
|
@@ -23740,9 +23758,9 @@ const useUIGroupStateController = (
|
|
|
23740
23758
|
const delegatedChildrenRef = useRef(new Map());
|
|
23741
23759
|
|
|
23742
23760
|
const groupIsRenderingRef = useRef(false);
|
|
23743
|
-
const pendingChangeRef = useRef(
|
|
23761
|
+
const pendingChangeRef = useRef(null);
|
|
23744
23762
|
groupIsRenderingRef.current = true;
|
|
23745
|
-
pendingChangeRef.current =
|
|
23763
|
+
pendingChangeRef.current = null;
|
|
23746
23764
|
|
|
23747
23765
|
const isMonitoringChild = (childUIStateController) => {
|
|
23748
23766
|
if (childUIStateController.isProxy) return false;
|
|
@@ -23774,7 +23792,18 @@ const useUIGroupStateController = (
|
|
|
23774
23792
|
// signals/pubsub without needing external refs.
|
|
23775
23793
|
const onChange = (e, { notifyExternal }) => {
|
|
23776
23794
|
if (groupIsRenderingRef.current) {
|
|
23777
|
-
|
|
23795
|
+
// Held until the layout effect below, WITH what it asked for: a child
|
|
23796
|
+
// whose bound signal was written from the outside changes during the
|
|
23797
|
+
// render that follows, and replaying that as a mount sync is what
|
|
23798
|
+
// makes a group silently drift — its own state comes up to date while
|
|
23799
|
+
// the form around it is never told anything moved. A real change
|
|
23800
|
+
// deferred alongside a mount sync stays a real change.
|
|
23801
|
+
const pendingChange = pendingChangeRef.current;
|
|
23802
|
+
pendingChangeRef.current = {
|
|
23803
|
+
e,
|
|
23804
|
+
notifyExternal:
|
|
23805
|
+
pendingChange?.notifyExternal === true ? true : notifyExternal,
|
|
23806
|
+
};
|
|
23778
23807
|
return;
|
|
23779
23808
|
}
|
|
23780
23809
|
const aggChildState = resolvedAggregateChildStates(
|
|
@@ -24238,12 +24267,16 @@ const useUIGroupStateController = (
|
|
|
24238
24267
|
|
|
24239
24268
|
useLayoutEffect(() => {
|
|
24240
24269
|
groupIsRenderingRef.current = false;
|
|
24241
|
-
|
|
24242
|
-
|
|
24243
|
-
|
|
24244
|
-
|
|
24245
|
-
{
|
|
24270
|
+
const pendingChange = pendingChangeRef.current;
|
|
24271
|
+
if (pendingChange) {
|
|
24272
|
+
pendingChangeRef.current = null;
|
|
24273
|
+
const batchedEvent = new CustomEvent(
|
|
24274
|
+
`${controlType}_batched_ui_state_update`,
|
|
24246
24275
|
);
|
|
24276
|
+
chainEvent(batchedEvent, pendingChange.e);
|
|
24277
|
+
scope._onChange(batchedEvent, {
|
|
24278
|
+
notifyExternal: pendingChange.notifyExternal,
|
|
24279
|
+
});
|
|
24247
24280
|
}
|
|
24248
24281
|
});
|
|
24249
24282
|
|
|
@@ -24304,20 +24337,6 @@ const useUIFacadeStateController = (props, realUIStateController) => {
|
|
|
24304
24337
|
// not be treated as the picker's synced child.
|
|
24305
24338
|
return false;
|
|
24306
24339
|
}
|
|
24307
|
-
if (props.type === "form" && childController.controlType !== "form") {
|
|
24308
|
-
// Only a form: what a type="form" picker syncs with is the form in
|
|
24309
|
-
// its popup, not any control that happens to be in there (an input
|
|
24310
|
-
// sitting outside the form, a ControlGroup — which is a way of
|
|
24311
|
-
// grouping controls INSIDE a form, not a thing a picker talks to).
|
|
24312
|
-
return false;
|
|
24313
|
-
}
|
|
24314
|
-
if (
|
|
24315
|
-
props.type === "array" &&
|
|
24316
|
-
childController.controlType !== "checkbox_group"
|
|
24317
|
-
) {
|
|
24318
|
-
// only selectable list expose array, ignore others
|
|
24319
|
-
return false;
|
|
24320
|
-
}
|
|
24321
24340
|
return true;
|
|
24322
24341
|
};
|
|
24323
24342
|
|
|
@@ -24334,8 +24353,10 @@ const useUIFacadeStateController = (props, realUIStateController) => {
|
|
|
24334
24353
|
const childType = child.controlType;
|
|
24335
24354
|
if (firstChildControllerRef.current) {
|
|
24336
24355
|
console.warn(
|
|
24337
|
-
`[
|
|
24338
|
-
`
|
|
24356
|
+
`[navi] a second control ("${childType}"${child.name ? ` name="${child.name}"` : ""}) registered in the ${describePicker(props)} popup. ` +
|
|
24357
|
+
`A picker talks to ONE control: the first one receives the picker's whole value and is the only one read back, ` +
|
|
24358
|
+
`so this one is neither filled nor collected. ` +
|
|
24359
|
+
`A popup holding several values needs one group around them — wrap them in a <ControlGroup>, name each control inside it, and give the picker type="object".`,
|
|
24339
24360
|
child,
|
|
24340
24361
|
);
|
|
24341
24362
|
} else {
|
|
@@ -24463,6 +24484,9 @@ const useUIFacadeStateController = (props, realUIStateController) => {
|
|
|
24463
24484
|
return scope.controller;
|
|
24464
24485
|
};
|
|
24465
24486
|
|
|
24487
|
+
const describePicker = (props) =>
|
|
24488
|
+
`<Picker${props.name ? ` name="${props.name}"` : ""}${props.type ? ` type="${props.type}"` : ""}>`;
|
|
24489
|
+
|
|
24466
24490
|
/**
|
|
24467
24491
|
* Returns true when `e` should trigger parent notification (child → parent bubbling).
|
|
24468
24492
|
*
|
|
@@ -24612,12 +24636,19 @@ const ControlgroupChildrenWrapper = ({
|
|
|
24612
24636
|
*/
|
|
24613
24637
|
const useControlProps = (props, {
|
|
24614
24638
|
controlType,
|
|
24615
|
-
allowNameless,
|
|
24639
|
+
allowNameless: allowNamelessByDefault,
|
|
24616
24640
|
persists,
|
|
24617
24641
|
uiActionInternal
|
|
24618
24642
|
}) => {
|
|
24619
24643
|
const debugUIState = useDebugUIState();
|
|
24620
24644
|
const debugAction = useDebugAction();
|
|
24645
|
+
|
|
24646
|
+
// A control that is not a field: it opens something, it goes somewhere, and
|
|
24647
|
+
// the group around it must expect no value from it — no name, and no warning
|
|
24648
|
+
// about the missing name. Buttons and links say so from inside navi; the prop
|
|
24649
|
+
// is how a control used as a door says the same thing from the outside.
|
|
24650
|
+
const allowNameless = props.allowNameless ?? allowNamelessByDefault;
|
|
24651
|
+
delete props.allowNameless;
|
|
24621
24652
|
const idDefault = useId();
|
|
24622
24653
|
const controlId = useContext(ControlIdContext);
|
|
24623
24654
|
props.id = props.id || controlId || idDefault;
|
|
@@ -25426,6 +25457,18 @@ const createControlInfo = (props, {
|
|
|
25426
25457
|
// getDefaultEventReactionDefinitions.
|
|
25427
25458
|
readOnlySupported = controlType === "picker" && INPUT_TYPE_SUPPORTING_READONLY_SET.has(typeProp);
|
|
25428
25459
|
}
|
|
25460
|
+
|
|
25461
|
+
// The suggestion the control starts on, as opposed to what it holds — what a
|
|
25462
|
+
// reset goes back to, and what tells a field left on its default from one
|
|
25463
|
+
// carrying an answer (see isUIStateHeld in held_ui_state.js).
|
|
25464
|
+
let defaultValue;
|
|
25465
|
+
if (!hasStateProp) {
|
|
25466
|
+
if (signalHoldsChecked) {
|
|
25467
|
+
defaultValue = props.defaultChecked ? value : undefined;
|
|
25468
|
+
} else if (Object.hasOwn(props, "defaultValue")) {
|
|
25469
|
+
defaultValue = props.defaultValue;
|
|
25470
|
+
}
|
|
25471
|
+
}
|
|
25429
25472
|
return {
|
|
25430
25473
|
controlType,
|
|
25431
25474
|
statePropName,
|
|
@@ -25434,6 +25477,7 @@ const createControlInfo = (props, {
|
|
|
25434
25477
|
stateInitial,
|
|
25435
25478
|
state: stateInitial,
|
|
25436
25479
|
value,
|
|
25480
|
+
defaultValue,
|
|
25437
25481
|
signal,
|
|
25438
25482
|
signalHoldsChecked,
|
|
25439
25483
|
stateFromSignal,
|
|
@@ -28339,6 +28383,20 @@ const css$V = /* css */`
|
|
|
28339
28383
|
backdrop-filter: var(--navi-backdrop-capture-backdrop-filter);
|
|
28340
28384
|
}
|
|
28341
28385
|
|
|
28386
|
+
/* backdropAppearance, keyed off the originating element (a
|
|
28387
|
+
pseudo-element carries no attributes of its own — same reasoning as
|
|
28388
|
+
the capture rule just above). After the rules it overrides: same
|
|
28389
|
+
specificity, so order is what decides. showModal() still makes the
|
|
28390
|
+
page inert either way — only the paint goes away. */
|
|
28391
|
+
&[data-backdrop-appearance="discrete"]::backdrop {
|
|
28392
|
+
background: var(--navi-backdrop-discrete-background);
|
|
28393
|
+
backdrop-filter: none;
|
|
28394
|
+
}
|
|
28395
|
+
&[data-backdrop-appearance="none"]::backdrop {
|
|
28396
|
+
background: transparent;
|
|
28397
|
+
backdrop-filter: none;
|
|
28398
|
+
}
|
|
28399
|
+
|
|
28342
28400
|
/* Nested under &[navi-animation] (not the other way around) so every
|
|
28343
28401
|
attribute selector compiles *before* ::backdrop, not after — a
|
|
28344
28402
|
pseudo-element can't be qualified by an attribute of its own
|
|
@@ -28458,6 +28516,18 @@ const css$V = /* css */`
|
|
|
28458
28516
|
backdrop-filter: var(--navi-backdrop-capture-backdrop-filter);
|
|
28459
28517
|
}
|
|
28460
28518
|
|
|
28519
|
+
/* Same override as the via-attribute renderer's own ::backdrop rules
|
|
28520
|
+
above, on the real element this renderer uses instead — see them for
|
|
28521
|
+
the specificity/ordering reasoning. */
|
|
28522
|
+
&[data-backdrop-appearance="discrete"] {
|
|
28523
|
+
background: var(--navi-backdrop-discrete-background);
|
|
28524
|
+
backdrop-filter: none;
|
|
28525
|
+
}
|
|
28526
|
+
&[data-backdrop-appearance="none"] {
|
|
28527
|
+
background: transparent;
|
|
28528
|
+
backdrop-filter: none;
|
|
28529
|
+
}
|
|
28530
|
+
|
|
28461
28531
|
&[navi-animation] {
|
|
28462
28532
|
opacity: 1;
|
|
28463
28533
|
transition-property: display, opacity;
|
|
@@ -28528,6 +28598,13 @@ const css$V = /* css */`
|
|
|
28528
28598
|
* both just absorb the click without closing (visually dimmed backdrop vs.
|
|
28529
28599
|
* not) — a dialog is always modal one way or another, so there's always
|
|
28530
28600
|
* at least a click-absorbing backdrop regardless of this prop.
|
|
28601
|
+
* @param {"auto"|"discrete"|"none"} [props.backdropAppearance="auto"] - How
|
|
28602
|
+
* visible the backdrop is, independently of what it does. `"auto"`: the
|
|
28603
|
+
* paint `pointerInteractionOutsideEffect` implies (dimmed for
|
|
28604
|
+
* `"close"`/`"cancel"`, blurred glass for `"capture"`). `"discrete"`: a
|
|
28605
|
+
* barely-there dim. `"none"`: fully transparent. The dialog stays modal
|
|
28606
|
+
* either way — this only changes how much it insists visually, never what
|
|
28607
|
+
* an outside click does or whether the page behind stays reachable.
|
|
28531
28608
|
* @param {boolean} [props.scrollCapture] - Traps scroll gestures inside the
|
|
28532
28609
|
* dialog so the page/container behind it can't scroll while it's open.
|
|
28533
28610
|
* A `layer="local"` dialog always locks its own positioned ancestor's
|
|
@@ -28775,6 +28852,11 @@ const useDialogProps = props => {
|
|
|
28775
28852
|
// there's no native inert-ing, so the real backdrop below is what
|
|
28776
28853
|
// actually makes "capture"/"none" behave the same way here too.
|
|
28777
28854
|
pointerInteractionOutsideEffect = "close",
|
|
28855
|
+
// How loudly the backdrop says it is there — independent of what it
|
|
28856
|
+
// *does* (that's pointerInteractionOutsideEffect above). A dialog is
|
|
28857
|
+
// always modal, so "none" here never makes the page behind reachable:
|
|
28858
|
+
// it only stops the dim from being drawn.
|
|
28859
|
+
backdropAppearance = "auto",
|
|
28778
28860
|
scrollCapture: scrollCaptureProp,
|
|
28779
28861
|
// "auto" (default) → the dialog follows its content. "frozen" → measured
|
|
28780
28862
|
// once, held at that size while open. See this prop's own JSDoc above.
|
|
@@ -29293,7 +29375,8 @@ const useDialogProps = props => {
|
|
|
29293
29375
|
"navi-hidden": openController.opened ? undefined : "",
|
|
29294
29376
|
"styleCSSVars": DIALOG_STYLE_CSS_VARS,
|
|
29295
29377
|
"animationDuration": rest.animationDuration,
|
|
29296
|
-
"data-pointer-interaction-outside": pointerInteractionOutsideEffect
|
|
29378
|
+
"data-pointer-interaction-outside": pointerInteractionOutsideEffect,
|
|
29379
|
+
"data-backdrop-appearance": backdropAppearance
|
|
29297
29380
|
});
|
|
29298
29381
|
Object.assign(contentProps, {
|
|
29299
29382
|
tabIndex,
|
|
@@ -29322,6 +29405,11 @@ const useDialogProps = props => {
|
|
|
29322
29405
|
// real backdrop element already gets the same attribute via
|
|
29323
29406
|
// backdropProps above, which is what its own CSS actually keys off).
|
|
29324
29407
|
"data-pointer-interaction-outside": pointerInteractionOutsideEffect,
|
|
29408
|
+
// Only load-bearing for the via-attribute renderer's own native
|
|
29409
|
+
// ::backdrop, same "a pseudo-element can't carry attributes" reasoning
|
|
29410
|
+
// as the prop just above (and harmless for the custom renderer, whose
|
|
29411
|
+
// real backdrop element gets it via backdropProps).
|
|
29412
|
+
"data-backdrop-appearance": backdropAppearance,
|
|
29325
29413
|
"styleCSSVars": DIALOG_STYLE_CSS_VARS,
|
|
29326
29414
|
...rest,
|
|
29327
29415
|
...autoFocusProps,
|
|
@@ -29724,6 +29812,20 @@ const css$U = /* css */`
|
|
|
29724
29812
|
backdrop-filter: var(--navi-backdrop-capture-backdrop-filter);
|
|
29725
29813
|
}
|
|
29726
29814
|
|
|
29815
|
+
/* backdropAppearance overrides whatever the effect above picked — same
|
|
29816
|
+
specificity (class + one attribute), so these have to stay *after*
|
|
29817
|
+
them to win. Only the paint changes: the element is still rendered
|
|
29818
|
+
and still pointer-events: auto, so an outside click keeps doing
|
|
29819
|
+
exactly what pointerInteractionOutsideEffect says. */
|
|
29820
|
+
&[data-backdrop-appearance="discrete"] {
|
|
29821
|
+
background: var(--navi-backdrop-discrete-background);
|
|
29822
|
+
backdrop-filter: none;
|
|
29823
|
+
}
|
|
29824
|
+
&[data-backdrop-appearance="none"] {
|
|
29825
|
+
background: transparent;
|
|
29826
|
+
backdrop-filter: none;
|
|
29827
|
+
}
|
|
29828
|
+
|
|
29727
29829
|
/* navi-animation mirrors the content popover's own resolved value (set
|
|
29728
29830
|
imperatively in openEffect) — the backdrop only ever fades, regardless
|
|
29729
29831
|
of which kind it is (translate/scale wouldn't mean anything on it).
|
|
@@ -29787,6 +29889,15 @@ const css$U = /* css */`
|
|
|
29787
29889
|
* absorbs the click (dims the backdrop) without closing. Note this
|
|
29788
29890
|
* default differs from `Dialog`'s own (`"close"`) — a popover is
|
|
29789
29891
|
* typically a lightweight, non-modal affordance.
|
|
29892
|
+
* @param {"auto"|"discrete"|"none"} [props.backdropAppearance="auto"] - How
|
|
29893
|
+
* visible the backdrop is, independently of what it does. `"auto"`: the
|
|
29894
|
+
* paint `pointerInteractionOutsideEffect` implies (dimmed for
|
|
29895
|
+
* `"close"`/`"cancel"`, blurred glass for `"capture"`). `"discrete"`: a
|
|
29896
|
+
* barely-there dim. `"none"`: fully transparent. The backdrop is still
|
|
29897
|
+
* rendered and still catches outside clicks in every case — this only
|
|
29898
|
+
* changes how much the popover insists on being the thing you deal with.
|
|
29899
|
+
* Ignored when `pointerInteractionOutsideEffect="none"` (there is no
|
|
29900
|
+
* backdrop at all then, and outside clicks pass through).
|
|
29790
29901
|
* @param {boolean} [props.scrollCapture] - Traps scroll gestures inside the
|
|
29791
29902
|
* popover so the page/container behind it can't scroll while it's open.
|
|
29792
29903
|
* @param {boolean} [props.focusCapture] - Traps Tab navigation inside the
|
|
@@ -30031,6 +30142,11 @@ const usePopoverProps = props => {
|
|
|
30031
30142
|
// "capture"→ absorb the press, stay open
|
|
30032
30143
|
// "none" → no backdrop
|
|
30033
30144
|
pointerInteractionOutsideEffect = "none",
|
|
30145
|
+
// How loudly the backdrop says it is there — independent of what it
|
|
30146
|
+
// *does* (that's pointerInteractionOutsideEffect above). "auto" keeps
|
|
30147
|
+
// the paint the effect implies; "discrete"/"none" tone it down or
|
|
30148
|
+
// remove it entirely without giving up the outside click.
|
|
30149
|
+
backdropAppearance = "auto",
|
|
30034
30150
|
scrollCapture,
|
|
30035
30151
|
focusCapture,
|
|
30036
30152
|
// "auto" (default) → the popover follows its content. "frozen" → measured
|
|
@@ -30704,6 +30820,7 @@ const usePopoverProps = props => {
|
|
|
30704
30820
|
"styleCSSVars": POPUP_STYLE_CSS_VARS,
|
|
30705
30821
|
"animationDuration": rest.animationDuration,
|
|
30706
30822
|
"data-pointer-interaction-outside": pointerInteractionOutsideEffect,
|
|
30823
|
+
"data-backdrop-appearance": backdropAppearance,
|
|
30707
30824
|
"onMouseDown": mouseDownEvent => {
|
|
30708
30825
|
if (mouseDownEvent.button !== 0) {
|
|
30709
30826
|
return;
|
|
@@ -32230,6 +32347,12 @@ const defaultRerunOn = {
|
|
|
32230
32347
|
GET: false,
|
|
32231
32348
|
GET_MANY: ["POST"],
|
|
32232
32349
|
};
|
|
32350
|
+
// What makes a range reader stale (rerunOn.GET_RANGE overrides it). DELETE is
|
|
32351
|
+
// in there, unlike for GET_MANY: an action holds ids and the store drops the
|
|
32352
|
+
// deleted one out of every list holding it, while a list reading by slices
|
|
32353
|
+
// holds places — the row that left takes the ones after it one rank up, and
|
|
32354
|
+
// only the collection knows who fills the last one.
|
|
32355
|
+
const defaultInvalidateRangeOn = ["POST", "DELETE"];
|
|
32233
32356
|
|
|
32234
32357
|
// This handles ALL resource lifecycle logic (rerun/reset) across all resources
|
|
32235
32358
|
const createResourceLifecycleManager = () => {
|
|
@@ -32250,6 +32373,7 @@ const createResourceLifecycleManager = () => {
|
|
|
32250
32373
|
paramScope,
|
|
32251
32374
|
uniqueKeys,
|
|
32252
32375
|
restActionSet: new Set(),
|
|
32376
|
+
rangeReaderSet: new Set(),
|
|
32253
32377
|
});
|
|
32254
32378
|
|
|
32255
32379
|
// Register dependencies
|
|
@@ -32262,6 +32386,14 @@ const createResourceLifecycleManager = () => {
|
|
|
32262
32386
|
}
|
|
32263
32387
|
}
|
|
32264
32388
|
};
|
|
32389
|
+
// Only the reader the resource exposes is registered; the ones `bindParams`
|
|
32390
|
+
// makes share its signal (see resource_range_reader.js).
|
|
32391
|
+
const registerRangeReader = (resourceScope, rangeReader) => {
|
|
32392
|
+
const config = registeredResources.get(resourceScope);
|
|
32393
|
+
if (config) {
|
|
32394
|
+
config.rangeReaderSet.add(rangeReader);
|
|
32395
|
+
}
|
|
32396
|
+
};
|
|
32265
32397
|
const registerAction = (resourceScope, restAction) => {
|
|
32266
32398
|
const config = registeredResources.get(resourceScope);
|
|
32267
32399
|
if (config) {
|
|
@@ -32492,7 +32624,39 @@ const createResourceLifecycleManager = () => {
|
|
|
32492
32624
|
};
|
|
32493
32625
|
};
|
|
32494
32626
|
|
|
32627
|
+
// Which readers hold slices of a collection the mutation just changed: the
|
|
32628
|
+
// one of the resource itself, and the ones of the resources that said they
|
|
32629
|
+
// depend on it.
|
|
32630
|
+
const invalidateRangeReaders = (
|
|
32631
|
+
triggeringAction,
|
|
32632
|
+
triggeringActionContext,
|
|
32633
|
+
) => {
|
|
32634
|
+
const triggerVerb = triggeringAction.meta.verb;
|
|
32635
|
+
const triggerResourceScope = triggeringActionContext.resourceScope;
|
|
32636
|
+
for (const [resourceScope, config] of registeredResources) {
|
|
32637
|
+
if (config.rangeReaderSet.size === 0) {
|
|
32638
|
+
continue;
|
|
32639
|
+
}
|
|
32640
|
+
const isSameResource = resourceScope === triggerResourceScope;
|
|
32641
|
+
const isDependent = Boolean(
|
|
32642
|
+
triggerResourceScope &&
|
|
32643
|
+
resourceDependencies.get(triggerResourceScope)?.has(resourceScope),
|
|
32644
|
+
);
|
|
32645
|
+
if (!isSameResource && !isDependent) {
|
|
32646
|
+
continue;
|
|
32647
|
+
}
|
|
32648
|
+
const invalidateOn = config.rerunOn.GET_RANGE ?? defaultInvalidateRangeOn;
|
|
32649
|
+
if (!shouldRerunAfter(invalidateOn, triggerVerb)) {
|
|
32650
|
+
continue;
|
|
32651
|
+
}
|
|
32652
|
+
for (const rangeReader of config.rangeReaderSet) {
|
|
32653
|
+
rangeReader.invalidate();
|
|
32654
|
+
}
|
|
32655
|
+
}
|
|
32656
|
+
};
|
|
32657
|
+
|
|
32495
32658
|
const onActionComplete = (restActionWhoJustCompleted, restActionContext) => {
|
|
32659
|
+
invalidateRangeReaders(restActionWhoJustCompleted, restActionContext);
|
|
32496
32660
|
const { actionsToRerun, actionsToReset, reasons } = findEffectOnActions(
|
|
32497
32661
|
restActionWhoJustCompleted,
|
|
32498
32662
|
restActionContext,
|
|
@@ -32511,6 +32675,7 @@ const createResourceLifecycleManager = () => {
|
|
|
32511
32675
|
return {
|
|
32512
32676
|
registerResource,
|
|
32513
32677
|
registerAction,
|
|
32678
|
+
registerRangeReader,
|
|
32514
32679
|
onActionComplete,
|
|
32515
32680
|
// Registers: when `triggerResource` fires, rerun `dependentResource`'s actions.
|
|
32516
32681
|
// Used by scopedMany to make the parent GET rerun when a child mutation completes.
|
|
@@ -32588,13 +32753,20 @@ const getParamScope = (params) => {
|
|
|
32588
32753
|
*
|
|
32589
32754
|
* The reader is a function, so a list feeds on it the way it feeds on any other
|
|
32590
32755
|
* source: `itemsAction={GAME.GET_RANGE.bindParams({ radar })}`.
|
|
32756
|
+
*
|
|
32757
|
+
* Keeping nothing does not mean hearing nothing: a mutation that decides who
|
|
32758
|
+
* belongs to the collection (a POST, a DELETE, whatever `rerunOn.GET_RANGE`
|
|
32759
|
+
* says) bumps `invalidationSignal`, and whoever reads slices through it goes
|
|
32760
|
+
* and asks again — the counterpart, for a reader, of what a rerun is for an
|
|
32761
|
+
* action. Every reader made by `bindParams` shares the signal of the one it
|
|
32762
|
+
* comes from: the params say which slices are read, not which collection.
|
|
32591
32763
|
*/
|
|
32592
32764
|
|
|
32593
32765
|
|
|
32594
32766
|
const createRangeReader = (
|
|
32595
32767
|
actionName,
|
|
32596
32768
|
callback,
|
|
32597
|
-
{ store, params: boundParams },
|
|
32769
|
+
{ store, params: boundParams, invalidationSignal = signal(0) },
|
|
32598
32770
|
) => {
|
|
32599
32771
|
const readRange = async (range = {}) => {
|
|
32600
32772
|
const { signal, ...rangeParams } = range;
|
|
@@ -32623,10 +32795,17 @@ const createRangeReader = (
|
|
|
32623
32795
|
};
|
|
32624
32796
|
Object.defineProperty(readRange, "name", { value: actionName });
|
|
32625
32797
|
readRange.isRangeReader = true;
|
|
32798
|
+
// Bumped when the collection this reads has moved: the slices anyone holds
|
|
32799
|
+
// stand for a composition that is gone.
|
|
32800
|
+
readRange.invalidationSignal = invalidationSignal;
|
|
32801
|
+
readRange.invalidate = () => {
|
|
32802
|
+
invalidationSignal.value = invalidationSignal.peek() + 1;
|
|
32803
|
+
};
|
|
32626
32804
|
readRange.bindParams = (paramsToBind) => {
|
|
32627
32805
|
return createRangeReader(actionName, callback, {
|
|
32628
32806
|
store,
|
|
32629
32807
|
params: boundParams ? { ...boundParams, ...paramsToBind } : paramsToBind,
|
|
32808
|
+
invalidationSignal,
|
|
32630
32809
|
});
|
|
32631
32810
|
};
|
|
32632
32811
|
return readRange;
|
|
@@ -32693,9 +32872,11 @@ const debug$2 = (args) => {
|
|
|
32693
32872
|
* - GET_MANY / POST_MANY / … → an array of item objects
|
|
32694
32873
|
* - GET_RANGE → `{ items, start, count }`, one slice of the collection
|
|
32695
32874
|
*
|
|
32696
|
-
* `GET_RANGE` is a reader rather than an action: it keeps no value and
|
|
32697
|
-
*
|
|
32698
|
-
* (`itemsAction={USER.GET_RANGE.bindParams({ team })}`).
|
|
32875
|
+
* `GET_RANGE` is a reader rather than an action: it keeps no value and has nothing to
|
|
32876
|
+
* rerun, so a `<List.Items>` can feed on it slice by slice
|
|
32877
|
+
* (`itemsAction={USER.GET_RANGE.bindParams({ team })}`). A mutation listed in
|
|
32878
|
+
* `rerunOn.GET_RANGE` (`["POST", "DELETE"]` by default) tells it the collection moved,
|
|
32879
|
+
* and whoever holds slices reads them again.
|
|
32699
32880
|
*
|
|
32700
32881
|
* A sub-resource of the backend (`/games/:id/candidates`) must be modelled with a
|
|
32701
32882
|
* relationship method, never as an `op`/`type` discriminator dispatched inside one
|
|
@@ -33875,13 +34056,18 @@ ${originalActionName} source location: ${locationInfo}`,
|
|
|
33875
34056
|
continue;
|
|
33876
34057
|
}
|
|
33877
34058
|
if (restCallbackKey === "GET_RANGE") {
|
|
33878
|
-
// A range is read, never kept: no action,
|
|
34059
|
+
// A range is read, never kept: no action, nothing to rerun — only a
|
|
34060
|
+
// signal saying the slices anyone holds are out of date
|
|
33879
34061
|
// (see resource_range_reader.js).
|
|
33880
34062
|
stateFacade.GET_RANGE = createRangeReader(
|
|
33881
34063
|
`${name}.GET_RANGE`,
|
|
33882
34064
|
restCallback,
|
|
33883
34065
|
{ store, params },
|
|
33884
34066
|
);
|
|
34067
|
+
resourceLifecycleManager.registerRangeReader(
|
|
34068
|
+
stateFacade,
|
|
34069
|
+
stateFacade.GET_RANGE,
|
|
34070
|
+
);
|
|
33885
34071
|
continue;
|
|
33886
34072
|
}
|
|
33887
34073
|
const isMany = restCallbackKey.endsWith("_MANY");
|
|
@@ -47374,6 +47560,86 @@ const Editable = props => {
|
|
|
47374
47560
|
});
|
|
47375
47561
|
};
|
|
47376
47562
|
|
|
47563
|
+
/**
|
|
47564
|
+
* What a control HOLDS, as opposed to what it is showing.
|
|
47565
|
+
*
|
|
47566
|
+
* A `value` is held: the control was given it, so handing it back says nothing
|
|
47567
|
+
* new. A `defaultValue` is only a suggestion — an age that is usually 18, a
|
|
47568
|
+
* duration that is usually 1h30 — so the control holds nothing, and confirming
|
|
47569
|
+
* the suggestion IS an answer ("yes, 18"). A control bound to a signal falls on
|
|
47570
|
+
* whichever side the signal put it: a signal with something in it is an answer
|
|
47571
|
+
* (restored from the url, set by whoever owns it), an empty one leaves the
|
|
47572
|
+
* control on its suggestion.
|
|
47573
|
+
*
|
|
47574
|
+
* The same distinction Form makes across its fields (see readHeldUIState in
|
|
47575
|
+
* form.jsx), asked of a single control — which is what lets a Picker tell "the
|
|
47576
|
+
* user re-confirmed what was already chosen" (nothing new) from "the user
|
|
47577
|
+
* accepted the proposal" (an answer).
|
|
47578
|
+
*/
|
|
47579
|
+
|
|
47580
|
+
const isUIStateHeld = (controller) => {
|
|
47581
|
+
if (!controller) {
|
|
47582
|
+
return false;
|
|
47583
|
+
}
|
|
47584
|
+
// Given a value outright: held, whatever it is showing.
|
|
47585
|
+
if (controller.hasStateProp || controller.hasValueProp) {
|
|
47586
|
+
return true;
|
|
47587
|
+
}
|
|
47588
|
+
// A facade (a picker) shows what the control inside its popup holds, so that
|
|
47589
|
+
// is the one to ask — the facade itself was given nothing.
|
|
47590
|
+
const facadeChild = controller.facadeChild;
|
|
47591
|
+
if (facadeChild) {
|
|
47592
|
+
return isUIStateHeld(facadeChild);
|
|
47593
|
+
}
|
|
47594
|
+
const boundSignal = controller.props?.signal;
|
|
47595
|
+
if (boundSignal) {
|
|
47596
|
+
return boundSignal.value !== undefined;
|
|
47597
|
+
}
|
|
47598
|
+
// Uncontrolled with a suggestion: what it shows is that suggestion until it
|
|
47599
|
+
// differs from it.
|
|
47600
|
+
if (controller.defaultValue !== undefined) {
|
|
47601
|
+
return !compareTwoJsValues(controller.uiState, controller.defaultValue);
|
|
47602
|
+
}
|
|
47603
|
+
// A group holding nothing of its own is worth what its children are: two
|
|
47604
|
+
// wheels each on their own suggestion make a group still waiting for an
|
|
47605
|
+
// answer, one of them moved makes a group holding one.
|
|
47606
|
+
const childControllers = controller.getChildControllers?.() || [];
|
|
47607
|
+
if (childControllers.length > 0) {
|
|
47608
|
+
return childControllers.some((child) => isUIStateHeld(child));
|
|
47609
|
+
}
|
|
47610
|
+
return controller.uiState !== undefined;
|
|
47611
|
+
};
|
|
47612
|
+
|
|
47613
|
+
/**
|
|
47614
|
+
* Tell a control — and everything inside it — that what it is showing is now
|
|
47615
|
+
* the answer, without its state having to move.
|
|
47616
|
+
*
|
|
47617
|
+
* The state is already right; what has not happened is anyone saying so. A
|
|
47618
|
+
* control reports an answer through `onUIAction` (that is where a bound signal
|
|
47619
|
+
* is written, where `uiAction` fires), and a suggestion nobody touched never
|
|
47620
|
+
* got there. Confirming a picker is exactly that moment.
|
|
47621
|
+
*
|
|
47622
|
+
* Down the whole subtree because that is where the answer actually lives: a
|
|
47623
|
+
* picker holding a group of two wheels has one signal per wheel, and it is each
|
|
47624
|
+
* wheel that has to record what it is showing. Commands are skipped — a
|
|
47625
|
+
* `command` on a control is its reaction to being used, and this is a
|
|
47626
|
+
* confirmation happening elsewhere, whose own command (the picker's) is already
|
|
47627
|
+
* running.
|
|
47628
|
+
*/
|
|
47629
|
+
const commitUIStateAsAnswer = (controller, e) => {
|
|
47630
|
+
if (!controller) {
|
|
47631
|
+
return;
|
|
47632
|
+
}
|
|
47633
|
+
const answering = controller.facadeChild || controller;
|
|
47634
|
+
commitSubtree(answering, e);
|
|
47635
|
+
};
|
|
47636
|
+
const commitSubtree = (controller, e) => {
|
|
47637
|
+
controller.onUIAction?.(e, { skipCommand: true });
|
|
47638
|
+
for (const child of controller.getChildControllers?.() || []) {
|
|
47639
|
+
commitSubtree(child, e);
|
|
47640
|
+
}
|
|
47641
|
+
};
|
|
47642
|
+
|
|
47377
47643
|
/**
|
|
47378
47644
|
*
|
|
47379
47645
|
* Here we want the same behaviour as web standards:
|
|
@@ -47424,6 +47690,7 @@ const useFormGroup = props => {
|
|
|
47424
47690
|
};
|
|
47425
47691
|
delete propsForGroup.standalone;
|
|
47426
47692
|
delete propsForGroup.canSendWhileUnchanged;
|
|
47693
|
+
delete propsForGroup.pristineKey;
|
|
47427
47694
|
// Not the generic control `command`, which a control triggers on its own ui
|
|
47428
47695
|
// actions — here it is what follows a SUCCESSFUL submission. So it is kept
|
|
47429
47696
|
// out of the control machinery and left in the DOM for the send to read
|
|
@@ -47456,7 +47723,7 @@ const useFormGroup = props => {
|
|
|
47456
47723
|
// microtask away, so typing and pressing Enter right after must not be read
|
|
47457
47724
|
// against the state of the previous frame.
|
|
47458
47725
|
uiStateController.shouldRequestAction = value => Boolean(props.canSendWhileUnchanged) || !compareTwoJsValues(withoutEmptyFields(value), uiStateController.sentUIState);
|
|
47459
|
-
|
|
47726
|
+
useHeldUIStateAsSent(uiStateController, props.pristineKey);
|
|
47460
47727
|
useUnregisteredControlWarning(props.ref);
|
|
47461
47728
|
const {
|
|
47462
47729
|
basePseudoState,
|
|
@@ -47554,13 +47821,10 @@ const FormNested = props => {
|
|
|
47554
47821
|
});
|
|
47555
47822
|
};
|
|
47556
47823
|
|
|
47557
|
-
// What the form HOLDS, as opposed to what it is showing
|
|
47558
|
-
//
|
|
47559
|
-
//
|
|
47560
|
-
//
|
|
47561
|
-
// back IS an answer ("yes, 18"). A field bound to a signal falls on whichever
|
|
47562
|
-
// side the signal put it: one carrying a default seeds `defaultValue`, one
|
|
47563
|
-
// without controls the field outright.
|
|
47824
|
+
// What the form HOLDS, as opposed to what it is showing — field by field, the
|
|
47825
|
+
// question isUIStateHeld answers: a field it was given an answer for is held,
|
|
47826
|
+
// a field merely showing a suggestion is not, and confirming that suggestion IS
|
|
47827
|
+
// an answer ("yes, 18").
|
|
47564
47828
|
const readHeldUIState = uiStateController => {
|
|
47565
47829
|
const uiState = uiStateController.uiState;
|
|
47566
47830
|
// A form given a value holds all of it, whatever its fields say.
|
|
@@ -47571,7 +47835,7 @@ const readHeldUIState = uiStateController => {
|
|
|
47571
47835
|
...uiState
|
|
47572
47836
|
};
|
|
47573
47837
|
for (const child of uiStateController.getChildControllers?.() || []) {
|
|
47574
|
-
if (child.name && !child
|
|
47838
|
+
if (child.name && !isUIStateHeld(child)) {
|
|
47575
47839
|
delete held[child.name];
|
|
47576
47840
|
}
|
|
47577
47841
|
}
|
|
@@ -47601,10 +47865,22 @@ const withoutEmptyFields = uiState => {
|
|
|
47601
47865
|
// register themselves in their own effects, which run first — this is the
|
|
47602
47866
|
// earliest moment the form knows what it holds. Everything after this baseline
|
|
47603
47867
|
// is a real send moving it forward (see useFormGroup's own onnavi_action_end).
|
|
47604
|
-
const
|
|
47868
|
+
const useHeldUIStateAsSent = (uiStateController, pristineKey) => {
|
|
47869
|
+
// The render that brought a new pristineKey read `changed` against the
|
|
47870
|
+
// previous baseline, and nothing else is going to move: the button would stay
|
|
47871
|
+
// lit on a form that holds exactly what it was just given. So ask for the one
|
|
47872
|
+
// render that reads the new baseline — the first one has nobody to tell,
|
|
47873
|
+
// every field it is waiting for re-renders the form as it registers.
|
|
47874
|
+
const [, rereadBaseline] = useState(0);
|
|
47875
|
+
const isFirstRef = useRef(true);
|
|
47605
47876
|
useLayoutEffect(() => {
|
|
47606
47877
|
uiStateController.sentUIState = readHeldUIState(uiStateController);
|
|
47607
|
-
|
|
47878
|
+
if (isFirstRef.current) {
|
|
47879
|
+
isFirstRef.current = false;
|
|
47880
|
+
return;
|
|
47881
|
+
}
|
|
47882
|
+
rereadBaseline(count => count + 1);
|
|
47883
|
+
}, [uiStateController, pristineKey]);
|
|
47608
47884
|
};
|
|
47609
47885
|
const useUnregisteredControlWarning = ref => {
|
|
47610
47886
|
// No dependency array: fields appear and disappear as the form re-renders,
|
|
@@ -47914,6 +48190,29 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
47914
48190
|
* container of ours could ever hold two of them side by side and translate the
|
|
47915
48191
|
* pair. One popup holding slides of its own contents has no such problem — and
|
|
47916
48192
|
* it is the same component in the document, in a dialog or in a popover.
|
|
48193
|
+
*
|
|
48194
|
+
* Padding belongs on the SLIDE, never on this box nor on anything above it.
|
|
48195
|
+
* Overflow clips at the PADDING edge, so a padding given to the container is a
|
|
48196
|
+
* band the clipping does not cover: a slide travelling through it is seen there
|
|
48197
|
+
* before it has reached the frame. And a padding above the slides does not
|
|
48198
|
+
* travel — the two contents crossing each other pass with nothing between them,
|
|
48199
|
+
* each flush against the other, instead of arriving already inset. Put on the
|
|
48200
|
+
* slide, the inset moves with what it insets, and a travel shows two paddings'
|
|
48201
|
+
* worth of gutter between the two.
|
|
48202
|
+
*
|
|
48203
|
+
* What SCROLLS is the slide too, and for the same reason read the other way
|
|
48204
|
+
* round: the box is as big as its largest slide, so a scroller placed above the
|
|
48205
|
+
* slides is always scrolling the tallest of them — stand on a short one and it
|
|
48206
|
+
* carries the scrollbar of a neighbour, scrolling through emptiness. The cap on
|
|
48207
|
+
* the height comes from above (a max-height on the popup, a column it has to
|
|
48208
|
+
* fit in) and must reach the slides as a CONSTRAINT rather than as a scroller:
|
|
48209
|
+
* this box shrinks into it (flex: 0 1 auto below), the grid hands that height
|
|
48210
|
+
* to every slide, and a slide with an overflow of its own scrolls only when ITS
|
|
48211
|
+
* content is taller than that. The tall slide scrolls; the short ones are tall
|
|
48212
|
+
* boxes with a short content in them, which is exactly what one wants — they
|
|
48213
|
+
* take the height the context imposes and ignore the height of their neighbour.
|
|
48214
|
+
* So: nothing scrollable between the cap and the slides (a shared [data-body]
|
|
48215
|
+
* around them IS a scroller, see box.jsx), and `overflow="auto"` on each Slide.
|
|
47917
48216
|
*/
|
|
47918
48217
|
const css$A = /* css */`
|
|
47919
48218
|
/* Where the picture stands relative to the slide that is current, in boxes
|
|
@@ -51238,6 +51537,11 @@ const css$z = /* css */`
|
|
|
51238
51537
|
* is unavoidably *more* intrusive once it switches to dialog mode than
|
|
51239
51538
|
* the exact same usage would be as a popover — worth keeping in mind for
|
|
51240
51539
|
* anything that relies on `Popup` and can end up on a small screen.
|
|
51540
|
+
* @param {"auto"|"discrete"|"none"} [props.backdropAppearance] - Forwarded
|
|
51541
|
+
* as-is to whichever component renders (both understand it identically):
|
|
51542
|
+
* how visible the backdrop is, independently of what an outside click
|
|
51543
|
+
* does. Unlike `pointerInteractionOutsideEffect` above, this one needs no
|
|
51544
|
+
* default here — `"auto"` already means the same thing on both sides.
|
|
51241
51545
|
* @param {boolean|"auto"|"fading"|"scaling"|"sliding"|"expanding"|`slide-from-${string}`|`expand-${string}`} [props.animation]
|
|
51242
51546
|
* - Forwarded as-is.
|
|
51243
51547
|
* @param {string} [props.animationDuration] - Forwarded as-is.
|
|
@@ -51344,86 +51648,6 @@ const Popup = props => {
|
|
|
51344
51648
|
});
|
|
51345
51649
|
};
|
|
51346
51650
|
|
|
51347
|
-
/**
|
|
51348
|
-
* What a control HOLDS, as opposed to what it is showing.
|
|
51349
|
-
*
|
|
51350
|
-
* A `value` is held: the control was given it, so handing it back says nothing
|
|
51351
|
-
* new. A `defaultValue` is only a suggestion — an age that is usually 18, a
|
|
51352
|
-
* duration that is usually 1h30 — so the control holds nothing, and confirming
|
|
51353
|
-
* the suggestion IS an answer ("yes, 18"). A control bound to a signal falls on
|
|
51354
|
-
* whichever side the signal put it: a signal with something in it is an answer
|
|
51355
|
-
* (restored from the url, set by whoever owns it), an empty one leaves the
|
|
51356
|
-
* control on its suggestion.
|
|
51357
|
-
*
|
|
51358
|
-
* The same distinction Form makes across its fields (see readHeldUIState in
|
|
51359
|
-
* form.jsx), asked of a single control — which is what lets a Picker tell "the
|
|
51360
|
-
* user re-confirmed what was already chosen" (nothing new) from "the user
|
|
51361
|
-
* accepted the proposal" (an answer).
|
|
51362
|
-
*/
|
|
51363
|
-
|
|
51364
|
-
const isUIStateHeld = (controller) => {
|
|
51365
|
-
if (!controller) {
|
|
51366
|
-
return false;
|
|
51367
|
-
}
|
|
51368
|
-
// Given a value outright: held, whatever it is showing.
|
|
51369
|
-
if (controller.hasStateProp || controller.hasValueProp) {
|
|
51370
|
-
return true;
|
|
51371
|
-
}
|
|
51372
|
-
// A facade (a picker) shows what the control inside its popup holds, so that
|
|
51373
|
-
// is the one to ask — the facade itself was given nothing.
|
|
51374
|
-
const facadeChild = controller.facadeChild;
|
|
51375
|
-
if (facadeChild) {
|
|
51376
|
-
return isUIStateHeld(facadeChild);
|
|
51377
|
-
}
|
|
51378
|
-
const boundSignal = controller.props?.signal;
|
|
51379
|
-
if (boundSignal) {
|
|
51380
|
-
return boundSignal.value !== undefined;
|
|
51381
|
-
}
|
|
51382
|
-
// Uncontrolled with a suggestion: what it shows is that suggestion until it
|
|
51383
|
-
// differs from it.
|
|
51384
|
-
if (controller.defaultValue !== undefined) {
|
|
51385
|
-
return !compareTwoJsValues(controller.uiState, controller.defaultValue);
|
|
51386
|
-
}
|
|
51387
|
-
// A group holding nothing of its own is worth what its children are: two
|
|
51388
|
-
// wheels each on their own suggestion make a group still waiting for an
|
|
51389
|
-
// answer, one of them moved makes a group holding one.
|
|
51390
|
-
const childControllers = controller.getChildControllers?.() || [];
|
|
51391
|
-
if (childControllers.length > 0) {
|
|
51392
|
-
return childControllers.some((child) => isUIStateHeld(child));
|
|
51393
|
-
}
|
|
51394
|
-
return controller.uiState !== undefined;
|
|
51395
|
-
};
|
|
51396
|
-
|
|
51397
|
-
/**
|
|
51398
|
-
* Tell a control — and everything inside it — that what it is showing is now
|
|
51399
|
-
* the answer, without its state having to move.
|
|
51400
|
-
*
|
|
51401
|
-
* The state is already right; what has not happened is anyone saying so. A
|
|
51402
|
-
* control reports an answer through `onUIAction` (that is where a bound signal
|
|
51403
|
-
* is written, where `uiAction` fires), and a suggestion nobody touched never
|
|
51404
|
-
* got there. Confirming a picker is exactly that moment.
|
|
51405
|
-
*
|
|
51406
|
-
* Down the whole subtree because that is where the answer actually lives: a
|
|
51407
|
-
* picker holding a group of two wheels has one signal per wheel, and it is each
|
|
51408
|
-
* wheel that has to record what it is showing. Commands are skipped — a
|
|
51409
|
-
* `command` on a control is its reaction to being used, and this is a
|
|
51410
|
-
* confirmation happening elsewhere, whose own command (the picker's) is already
|
|
51411
|
-
* running.
|
|
51412
|
-
*/
|
|
51413
|
-
const commitUIStateAsAnswer = (controller, e) => {
|
|
51414
|
-
if (!controller) {
|
|
51415
|
-
return;
|
|
51416
|
-
}
|
|
51417
|
-
const answering = controller.facadeChild || controller;
|
|
51418
|
-
commitSubtree(answering, e);
|
|
51419
|
-
};
|
|
51420
|
-
const commitSubtree = (controller, e) => {
|
|
51421
|
-
controller.onUIAction?.(e, { skipCommand: true });
|
|
51422
|
-
for (const child of controller.getChildControllers?.() || []) {
|
|
51423
|
-
commitSubtree(child, e);
|
|
51424
|
-
}
|
|
51425
|
-
};
|
|
51426
|
-
|
|
51427
51651
|
installImportMetaCssBuild(import.meta);const css$y = /* css */`
|
|
51428
51652
|
.navi_picker {
|
|
51429
51653
|
/* Sizing ceilings (maxmax), background, box-shadow, outline, padding,
|
|
@@ -52039,6 +52263,9 @@ const PickerContentInsidePopup = props => {
|
|
|
52039
52263
|
// action if the value changed) — Escape still cancels. Pass "cancel" to make
|
|
52040
52264
|
// clicking outside revert instead, or "capture" to keep it open.
|
|
52041
52265
|
pointerInteractionOutsideEffect = "close",
|
|
52266
|
+
// Named/forwarded rather than left in ...rest: rest goes to the picker
|
|
52267
|
+
// element itself, not the popup, and this belongs to the popup.
|
|
52268
|
+
backdropAppearance,
|
|
52042
52269
|
dialogExpand,
|
|
52043
52270
|
dialogExpandX,
|
|
52044
52271
|
dialogExpandY,
|
|
@@ -52089,6 +52316,7 @@ const PickerContentInsidePopup = props => {
|
|
|
52089
52316
|
marginWithContainer: marginWithContainer === undefined && isPopover ? popoverSpacing : marginWithContainer,
|
|
52090
52317
|
scrollCapture: scrollCapture,
|
|
52091
52318
|
pointerInteractionOutsideEffect: pointerLock ? "capture" : pointerInteractionOutsideEffect,
|
|
52319
|
+
backdropAppearance: backdropAppearance,
|
|
52092
52320
|
focusCapture: isPopover ? focusCapture : undefined,
|
|
52093
52321
|
expand: isPopover ? undefined : dialogExpand,
|
|
52094
52322
|
expandX: isPopover ? undefined : dialogExpandX,
|
|
@@ -53711,6 +53939,28 @@ const css$v = /* css */`
|
|
|
53711
53939
|
--list-border-width-default: 1px;
|
|
53712
53940
|
--list-border-color: light-dark(#ccc, #555);
|
|
53713
53941
|
--list-background-color: light-dark(#fff, #1e1e1e);
|
|
53942
|
+
|
|
53943
|
+
/* A sticky part paints over the rows only while it IS stuck — which is
|
|
53944
|
+
what --navi-z-index-sticky says it is for ("kept stuck while something
|
|
53945
|
+
scrolls under it"). At rest it is a block in the flow with nothing
|
|
53946
|
+
passing under it, and a 10 there is what slices whatever a neighbouring
|
|
53947
|
+
row lets out of its box: a focus ring, a badge, a stamp. See
|
|
53948
|
+
useStuckStickyParts for the navi-stuck attribute these read.
|
|
53949
|
+
|
|
53950
|
+
With "auto" at rest, a card whose badge overflows into the label
|
|
53951
|
+
below it gets past it by saying z-index: 1 on that badge — a literal,
|
|
53952
|
+
in the card, against its own neighbour, which is what docs/z_index.md
|
|
53953
|
+
asks for. These variables are the escape hatch for what that cannot
|
|
53954
|
+
reach, not the usual answer. Mind that a negative value here is
|
|
53955
|
+
compared against the page: it needs a stacking context between the
|
|
53956
|
+
label and the nearest opaque background, or the label goes behind that
|
|
53957
|
+
background and disappears. */
|
|
53958
|
+
--list-header-z-index: auto;
|
|
53959
|
+
--list-header-z-index-stuck: var(--navi-z-index-sticky);
|
|
53960
|
+
--list-footer-z-index: auto;
|
|
53961
|
+
--list-footer-z-index-stuck: var(--navi-z-index-sticky);
|
|
53962
|
+
--list-group-label-z-index: auto;
|
|
53963
|
+
--list-group-label-z-index-stuck: var(--navi-z-index-sticky);
|
|
53714
53964
|
}
|
|
53715
53965
|
.navi_list_item {
|
|
53716
53966
|
--list-item-padding-x-default: 0px;
|
|
@@ -54092,8 +54342,12 @@ const css$v = /* css */`
|
|
|
54092
54342
|
position: sticky;
|
|
54093
54343
|
top: 0;
|
|
54094
54344
|
left: 0;
|
|
54095
|
-
z-index: var(--
|
|
54345
|
+
z-index: var(--list-header-z-index);
|
|
54096
54346
|
order: -2;
|
|
54347
|
+
|
|
54348
|
+
&[navi-stuck] {
|
|
54349
|
+
z-index: var(--list-header-z-index-stuck);
|
|
54350
|
+
}
|
|
54097
54351
|
}
|
|
54098
54352
|
.navi_list_fallback,
|
|
54099
54353
|
.navi_list_search_fallback {
|
|
@@ -54199,8 +54453,12 @@ const css$v = /* css */`
|
|
|
54199
54453
|
position: sticky;
|
|
54200
54454
|
right: 0;
|
|
54201
54455
|
bottom: 0;
|
|
54202
|
-
z-index: var(--
|
|
54456
|
+
z-index: var(--list-footer-z-index);
|
|
54203
54457
|
order: 2;
|
|
54458
|
+
|
|
54459
|
+
&[navi-stuck] {
|
|
54460
|
+
z-index: var(--list-footer-z-index-stuck);
|
|
54461
|
+
}
|
|
54204
54462
|
}
|
|
54205
54463
|
|
|
54206
54464
|
::highlight(navi-search-match) {
|
|
@@ -54215,11 +54473,15 @@ const css$v = /* css */`
|
|
|
54215
54473
|
.navi_list_item_group_label {
|
|
54216
54474
|
position: sticky;
|
|
54217
54475
|
top: var(--list-group-label-top, var(--x-list-group-label-top, 0px));
|
|
54218
|
-
z-index: var(--
|
|
54476
|
+
z-index: var(--list-group-label-z-index);
|
|
54219
54477
|
display: block;
|
|
54220
54478
|
background-color: var(--list-group-label-background-color);
|
|
54221
54479
|
user-select: none;
|
|
54222
54480
|
|
|
54481
|
+
&[navi-stuck] {
|
|
54482
|
+
z-index: var(--list-group-label-z-index-stuck);
|
|
54483
|
+
}
|
|
54484
|
+
|
|
54223
54485
|
&[navi-default] {
|
|
54224
54486
|
padding: 4px 12px 2px;
|
|
54225
54487
|
color: light-dark(#888, #aaa);
|
|
@@ -54528,6 +54790,7 @@ const ListUI = props => {
|
|
|
54528
54790
|
expand: expand,
|
|
54529
54791
|
"navi-nothing-to-display": nothingToDisplay ? "" : undefined,
|
|
54530
54792
|
"navi-loading": loading ? "" : undefined,
|
|
54793
|
+
"navi-refreshing": virtual.refreshingSignal.value ? "" : undefined,
|
|
54531
54794
|
"navi-error": error ? "" : undefined,
|
|
54532
54795
|
styleCSSVars: LIST_STYLE_CSS_VARS,
|
|
54533
54796
|
pseudoClasses: LIST_PSEUDO_CLASSES,
|
|
@@ -54837,6 +55100,7 @@ const useListScrollSync = ({
|
|
|
54837
55100
|
};
|
|
54838
55101
|
useLayoutEffect(resolveScroller);
|
|
54839
55102
|
useStickyScrollportWarning();
|
|
55103
|
+
useStuckStickyParts(ref, getScroller, scrollerElResolved, horizontal);
|
|
54840
55104
|
|
|
54841
55105
|
// The row the scroll holds onto across a change of geometry, and where it
|
|
54842
55106
|
// sat when that change was decided. Captured at the two moments the list
|
|
@@ -55614,6 +55878,14 @@ const getScrollerViewportRect = scrollerEl => {
|
|
|
55614
55878
|
}
|
|
55615
55879
|
return scrollerEl.getBoundingClientRect();
|
|
55616
55880
|
};
|
|
55881
|
+
// What a sticky part of the list sticks to is the nearest scroll container in
|
|
55882
|
+
// the DOM; `scroller` has no say in it. A list told the page scrolls it can
|
|
55883
|
+
// therefore have its group labels and its header stuck to a wrapper that never
|
|
55884
|
+
// scrolls — and pushed down by that wrapper's scroll-padding on top of it. The
|
|
55885
|
+
// usual culprit is an app wrapper carrying `overflow-x: auto` to keep the
|
|
55886
|
+
// document from overflowing horizontally on mobile; `overflow-x: clip` keeps
|
|
55887
|
+
// that guarantee without making a scroll container.
|
|
55888
|
+
const STICKY_LIST_PART_SELECTOR = `.navi_list_item_header, .navi_list_item_footer, .navi_list_item_group_label`;
|
|
55617
55889
|
const useStickyScrollportWarning = (ref, scroller) => {
|
|
55618
55890
|
useRef(false);
|
|
55619
55891
|
useLayoutEffect(() => {
|
|
@@ -55623,6 +55895,142 @@ const useStickyScrollportWarning = (ref, scroller) => {
|
|
|
55623
55895
|
});
|
|
55624
55896
|
};
|
|
55625
55897
|
|
|
55898
|
+
/**
|
|
55899
|
+
* "Am I stuck?" — the question a `position: sticky` element cannot ask about
|
|
55900
|
+
* itself. There is no selector for it, and `scroll-state(stuck: top)` does not
|
|
55901
|
+
* answer it either: that query styles a container's DESCENDANTS, so a part
|
|
55902
|
+
* cannot read its own stuck state, which is exactly the one a background, a
|
|
55903
|
+
* shadow or a stacking order has to depend on.
|
|
55904
|
+
*
|
|
55905
|
+
* So the list says it, on the three parts it makes sticky: `navi-stuck` while a
|
|
55906
|
+
* part sits at the edge it sticks to, gone while it rides along in the flow.
|
|
55907
|
+
* The list is the right place for it because it is the only one that knows
|
|
55908
|
+
* WHICH box its parts stick to — an app writing this outside would listen to
|
|
55909
|
+
* the window and be right only for `scroller="document"` (see getScrollerEl,
|
|
55910
|
+
* and useStickyScrollportWarning for the case where even the list is wrong
|
|
55911
|
+
* about it: a scroll container between the two, which dev mode reports).
|
|
55912
|
+
*
|
|
55913
|
+
* What reads it is navi's own z-index rule first (see --list-*-z-index above:
|
|
55914
|
+
* the sticky band is for a part with something scrolling under it, not for a
|
|
55915
|
+
* block at rest in the flow), and an app second, for anything it wants to say
|
|
55916
|
+
* about a part being stuck.
|
|
55917
|
+
*/
|
|
55918
|
+
// Fractional layout is the rule, not the exception — zoom, screen density, a
|
|
55919
|
+
// scroller at a half-pixel offset. A part at its sticky offset can render a
|
|
55920
|
+
// fraction short of it, and without this slack it reads as being at rest: a
|
|
55921
|
+
// bug that shows up on one machine and not the next.
|
|
55922
|
+
const STUCK_SLACK = 1;
|
|
55923
|
+
// Which edge a part sticks to. The header and the footer stick along whichever
|
|
55924
|
+
// axis the list scrolls — their rules declare both insets (top/left, and
|
|
55925
|
+
// bottom/right) so the same markup works either way; a group label always caps
|
|
55926
|
+
// its group from the top.
|
|
55927
|
+
const getStickyEdge = (partEl, horizontal) => {
|
|
55928
|
+
if (partEl.classList.contains("navi_list_item_footer")) {
|
|
55929
|
+
return horizontal ? "right" : "bottom";
|
|
55930
|
+
}
|
|
55931
|
+
if (partEl.classList.contains("navi_list_item_header")) {
|
|
55932
|
+
return horizontal ? "left" : "top";
|
|
55933
|
+
}
|
|
55934
|
+
return "top";
|
|
55935
|
+
};
|
|
55936
|
+
// A sticky inset is measured from the scrollport — the padding box of the
|
|
55937
|
+
// scroller, or the viewport when the page scrolls. getScrollerViewportRect
|
|
55938
|
+
// gives the border box; the borders come off here, since a scroller with one
|
|
55939
|
+
// would otherwise read as a pixel of scrolling already done.
|
|
55940
|
+
const getScrollportRect = scrollerEl => {
|
|
55941
|
+
const rect = getScrollerViewportRect(scrollerEl);
|
|
55942
|
+
if (scrollerEl === document.scrollingElement) {
|
|
55943
|
+
return rect;
|
|
55944
|
+
}
|
|
55945
|
+
const top = rect.top + scrollerEl.clientTop;
|
|
55946
|
+
const left = rect.left + scrollerEl.clientLeft;
|
|
55947
|
+
return {
|
|
55948
|
+
top,
|
|
55949
|
+
left,
|
|
55950
|
+
bottom: top + scrollerEl.clientHeight,
|
|
55951
|
+
right: left + scrollerEl.clientWidth
|
|
55952
|
+
};
|
|
55953
|
+
};
|
|
55954
|
+
const isPartStuck = (partEl, edge, scrollportRect) => {
|
|
55955
|
+
// The inset is read computed, not from the rule: --list-group-label-top and
|
|
55956
|
+
// the FixedBar space behind it are what put the label where it sticks.
|
|
55957
|
+
const declared = parseFloat(getComputedStyle(partEl)[edge]);
|
|
55958
|
+
const inset = Number.isFinite(declared) ? declared : 0;
|
|
55959
|
+
const rect = partEl.getBoundingClientRect();
|
|
55960
|
+
if (edge === "top") {
|
|
55961
|
+
return rect.top - scrollportRect.top <= inset + STUCK_SLACK;
|
|
55962
|
+
}
|
|
55963
|
+
if (edge === "left") {
|
|
55964
|
+
return rect.left - scrollportRect.left <= inset + STUCK_SLACK;
|
|
55965
|
+
}
|
|
55966
|
+
if (edge === "bottom") {
|
|
55967
|
+
return scrollportRect.bottom - rect.bottom <= inset + STUCK_SLACK;
|
|
55968
|
+
}
|
|
55969
|
+
return scrollportRect.right - rect.right <= inset + STUCK_SLACK;
|
|
55970
|
+
};
|
|
55971
|
+
const useStuckStickyParts = (ref, getScroller, scrollerElResolved, horizontal) => {
|
|
55972
|
+
// Rewritten on every render so the listeners below, registered once per
|
|
55973
|
+
// scroller, always run against the current geometry.
|
|
55974
|
+
const updateRef = useRef(null);
|
|
55975
|
+
updateRef.current = () => {
|
|
55976
|
+
const listContainerEl = ref.current;
|
|
55977
|
+
if (!listContainerEl) {
|
|
55978
|
+
return;
|
|
55979
|
+
}
|
|
55980
|
+
const partEls = listContainerEl.querySelectorAll(STICKY_LIST_PART_SELECTOR);
|
|
55981
|
+
if (partEls.length === 0) {
|
|
55982
|
+
return;
|
|
55983
|
+
}
|
|
55984
|
+
const scrollerEl = getScroller();
|
|
55985
|
+
if (!scrollerEl) {
|
|
55986
|
+
return;
|
|
55987
|
+
}
|
|
55988
|
+
// One rect per RENDERED part: virtualization already bounds how many of
|
|
55989
|
+
// them exist, which is what keeps this affordable on every scroll event.
|
|
55990
|
+
const scrollportRect = getScrollportRect(scrollerEl);
|
|
55991
|
+
for (const partEl of partEls) {
|
|
55992
|
+
const edge = getStickyEdge(partEl, horizontal);
|
|
55993
|
+
partEl.toggleAttribute("navi-stuck", isPartStuck(partEl, edge, scrollportRect));
|
|
55994
|
+
}
|
|
55995
|
+
};
|
|
55996
|
+
|
|
55997
|
+
// Every commit, because a virtualized list changes which parts exist without
|
|
55998
|
+
// anything scrolling: group labels enter and leave the DOM as the window
|
|
55999
|
+
// moves, and one that arrives already at the edge has never been measured.
|
|
56000
|
+
useLayoutEffect(() => {
|
|
56001
|
+
updateRef.current();
|
|
56002
|
+
});
|
|
56003
|
+
useLayoutEffect(() => {
|
|
56004
|
+
const listContainerEl = ref.current;
|
|
56005
|
+
if (!listContainerEl) {
|
|
56006
|
+
return undefined;
|
|
56007
|
+
}
|
|
56008
|
+
const update = () => {
|
|
56009
|
+
// Synchronously, not on a rAF: scroll events are dispatched while the
|
|
56010
|
+
// frame is being put together, so the attribute lands in the same paint
|
|
56011
|
+
// as the scroll that caused it. A frame late is a frame of flicker.
|
|
56012
|
+
updateRef.current();
|
|
56013
|
+
};
|
|
56014
|
+
// A page-level scroller does not emit "scroll" on the element itself
|
|
56015
|
+
// (document.scrollingElement); the document does.
|
|
56016
|
+
const scrollerEl = getScroller();
|
|
56017
|
+
const scrollEventTarget = !scrollerEl || scrollerEl === document.scrollingElement ? document : scrollerEl;
|
|
56018
|
+
scrollEventTarget.addEventListener("scroll", update, {
|
|
56019
|
+
passive: true
|
|
56020
|
+
});
|
|
56021
|
+
window.addEventListener("resize", update);
|
|
56022
|
+
// The list growing under a scroller that has not moved — rows loaded by
|
|
56023
|
+
// scroll, a group unfolding — changes which parts sit at an edge.
|
|
56024
|
+
const observer = new ResizeObserver(update);
|
|
56025
|
+
observer.observe(listContainerEl);
|
|
56026
|
+
return () => {
|
|
56027
|
+
scrollEventTarget.removeEventListener("scroll", update);
|
|
56028
|
+
window.removeEventListener("resize", update);
|
|
56029
|
+
observer.disconnect();
|
|
56030
|
+
};
|
|
56031
|
+
}, [scrollerElResolved]);
|
|
56032
|
+
};
|
|
56033
|
+
|
|
55626
56034
|
// The CSS needs to tell "the page scrolls me" from "some box around me
|
|
55627
56035
|
// scrolls me": only the first one sticks to the viewport, where the fixed bars
|
|
55628
56036
|
// are.
|
|
@@ -56707,6 +57115,10 @@ const createListVirtual = () => {
|
|
|
56707
57115
|
// registers, nothing is drawn), and yet they are what it may have been
|
|
56708
57116
|
// waiting for — the row it was told to open on, for one.
|
|
56709
57117
|
const pagesSignal = signal(0);
|
|
57118
|
+
// How many runs are re-reading rows they already show. The list wears it as
|
|
57119
|
+
// an attribute: what is drawn is from before, and the app may want to say so
|
|
57120
|
+
// without taking anything away.
|
|
57121
|
+
const refreshingSignal = signal(0);
|
|
56710
57122
|
const placeByOwner = new Map();
|
|
56711
57123
|
const locatorByOwner = new Map();
|
|
56712
57124
|
let passId = 0;
|
|
@@ -56714,6 +57126,7 @@ const createListVirtual = () => {
|
|
|
56714
57126
|
const virtual = {
|
|
56715
57127
|
totalSignal,
|
|
56716
57128
|
pagesSignal,
|
|
57129
|
+
refreshingSignal,
|
|
56717
57130
|
// What a run needs to know about the list it lives in: how many rows the
|
|
56718
57131
|
// list is willing to draw at once, which end it opens on, and how much
|
|
56719
57132
|
// room one row is given — a row whose content has not arrived must take
|
|
@@ -56816,7 +57229,7 @@ const VISIBILITY_HIDDEN_STYLE = {
|
|
|
56816
57229
|
* inside `<List.Group>`s; each takes its place in declaration order.
|
|
56817
57230
|
*
|
|
56818
57231
|
* @type {import("ignore:preact").FunctionComponent<{
|
|
56819
|
-
* renderItem: (item: any, index: number) => import("ignore:preact").ComponentChildren,
|
|
57232
|
+
* renderItem: (item: any, index: number, state: {refreshing: boolean}) => import("ignore:preact").ComponentChildren,
|
|
56820
57233
|
* itemsAction: (range: {start: number, end: number, limit: number, before?: string, after?: string, around?: string, count?: number, signal: AbortSignal}) => any,
|
|
56821
57234
|
* count?: number,
|
|
56822
57235
|
* groupBy?: (item: any, index: number) => any,
|
|
@@ -56826,6 +57239,10 @@ const VISIBILITY_HIDDEN_STYLE = {
|
|
|
56826
57239
|
* renderSkeleton?: false | ((index: number) => import("ignore:preact").ComponentChildren),
|
|
56827
57240
|
* renderError?: (failure: {error: any, retry: () => void, start: number, end: number}) => import("ignore:preact").ComponentChildren,
|
|
56828
57241
|
* }>}
|
|
57242
|
+
* @param {(item: any, index: number, state: {refreshing: boolean}) => any} props.renderItem
|
|
57243
|
+
* What one row is, given the item and where it sits. `state.refreshing` says
|
|
57244
|
+
* the rows drawn are the ones from before while the run reads the collection
|
|
57245
|
+
* again — the list carries `navi-refreshing` for the same reason.
|
|
56829
57246
|
* @param {(item: any, index: number) => any} [props.groupBy]
|
|
56830
57247
|
* What tells rows that belong together apart from the others — the day of a
|
|
56831
57248
|
* message, the month of a game. Consecutive rows sharing it are wrapped in a
|
|
@@ -57032,6 +57449,9 @@ const ListItems = ({
|
|
|
57032
57449
|
virtualItemSize: virtualItemSize
|
|
57033
57450
|
}, "navi-list-filler-before"));
|
|
57034
57451
|
}
|
|
57452
|
+
const renderItemState = {
|
|
57453
|
+
refreshing: store.refreshing
|
|
57454
|
+
};
|
|
57035
57455
|
let rowIndex = windowFrom;
|
|
57036
57456
|
while (rowIndex < windowTo) {
|
|
57037
57457
|
if (rowIndex >= failureFrom && rowIndex <= failureTo) {
|
|
@@ -57059,7 +57479,7 @@ const ListItems = ({
|
|
|
57059
57479
|
const key = item === undefined ? `${ownerId}_skeleton_${rowIndex}` : idOf(item, rowIndex);
|
|
57060
57480
|
let rowVnode;
|
|
57061
57481
|
if (item !== undefined) {
|
|
57062
|
-
rowVnode = renderItem(item, rowIndex);
|
|
57482
|
+
rowVnode = renderItem(item, rowIndex, renderItemState);
|
|
57063
57483
|
} else if (renderRowSkeleton === false) {
|
|
57064
57484
|
// The row must still take its room: without it the rows below would
|
|
57065
57485
|
// climb up and slide back down as the answer arrives.
|
|
@@ -57160,6 +57580,22 @@ const useItemStore = ({
|
|
|
57160
57580
|
}
|
|
57161
57581
|
const pages = pagesRef.current;
|
|
57162
57582
|
const [, setPageVersion] = useState(0);
|
|
57583
|
+
// The rows held are out of date and the run has not asked for the new ones
|
|
57584
|
+
// yet. They stay on screen until the answer comes: what is drawn is from
|
|
57585
|
+
// before, which is not the same thing as nothing to draw.
|
|
57586
|
+
const staleRef = useRef(false);
|
|
57587
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
57588
|
+
// A source that says when what it reads has moved (a resource range reader
|
|
57589
|
+
// does: see rerunOn.GET_RANGE) is heard here — a write deciding who belongs
|
|
57590
|
+
// to the collection is exactly what a run cannot deduce from the rows it
|
|
57591
|
+
// holds. A source that says nothing is read once and stays as it is.
|
|
57592
|
+
const invalidationSignal = typeof itemsAction === "function" ? itemsAction.invalidationSignal : null;
|
|
57593
|
+
const invalidation = invalidationSignal ? invalidationSignal.value : 0;
|
|
57594
|
+
const invalidationRef = useRef(invalidation);
|
|
57595
|
+
if (invalidationRef.current !== invalidation) {
|
|
57596
|
+
invalidationRef.current = invalidation;
|
|
57597
|
+
staleRef.current = true;
|
|
57598
|
+
}
|
|
57163
57599
|
// The one request in flight, with the means to call it off: a page asked for
|
|
57164
57600
|
// a window the list has left is work the server and the browser are doing for
|
|
57165
57601
|
// nothing.
|
|
@@ -57169,7 +57605,8 @@ const useItemStore = ({
|
|
|
57169
57605
|
end: -1,
|
|
57170
57606
|
held: -1,
|
|
57171
57607
|
controller: null,
|
|
57172
|
-
generation: 0
|
|
57608
|
+
generation: 0,
|
|
57609
|
+
revalidating: false
|
|
57173
57610
|
});
|
|
57174
57611
|
// The rows asked for that never came. Kept as a range so the list can say
|
|
57175
57612
|
// where the hole is, and cleared by a retry — which is what makes the same
|
|
@@ -57180,9 +57617,24 @@ const useItemStore = ({
|
|
|
57180
57617
|
// It stands for a windowful of them: a list that is about to be filled looks
|
|
57181
57618
|
// like rows on their way, not like an empty list.
|
|
57182
57619
|
const rowCount = pages.count ?? count ?? virtual.renderBudget;
|
|
57620
|
+
// A run that never received anything has nothing to keep on screen: asking
|
|
57621
|
+
// again is its first ask, not a refresh.
|
|
57622
|
+
if (staleRef.current && pages.count === undefined) {
|
|
57623
|
+
staleRef.current = false;
|
|
57624
|
+
}
|
|
57625
|
+
useLayoutEffect(() => {
|
|
57626
|
+
if (!refreshing) {
|
|
57627
|
+
return null;
|
|
57628
|
+
}
|
|
57629
|
+
virtual.refreshingSignal.value = virtual.refreshingSignal.peek() + 1;
|
|
57630
|
+
return () => {
|
|
57631
|
+
virtual.refreshingSignal.value = virtual.refreshingSignal.peek() - 1;
|
|
57632
|
+
};
|
|
57633
|
+
}, [refreshing]);
|
|
57183
57634
|
const store = {
|
|
57184
57635
|
rowCount,
|
|
57185
57636
|
failure,
|
|
57637
|
+
refreshing,
|
|
57186
57638
|
// JS memory is cheap next to the DOM, but a long enough scroll accumulates
|
|
57187
57639
|
// everything it ever went through. Rows far from what is on screen are
|
|
57188
57640
|
// dropped and simply asked for again if the user goes back — the same
|
|
@@ -57223,7 +57675,25 @@ const useItemStore = ({
|
|
|
57223
57675
|
let start = missingStart;
|
|
57224
57676
|
let end = missingEnd;
|
|
57225
57677
|
let around;
|
|
57226
|
-
|
|
57678
|
+
// Rows that are all there but out of date: the ask is the window itself,
|
|
57679
|
+
// anchored on the row at its top — a source paginating by cursor gets a
|
|
57680
|
+
// row to count from, and the reading position is what must survive.
|
|
57681
|
+
const revalidating = staleRef.current;
|
|
57682
|
+
if (revalidating) {
|
|
57683
|
+
start = windowFrom;
|
|
57684
|
+
end = windowTo - 1;
|
|
57685
|
+
if (end < start) {
|
|
57686
|
+
// Nothing of this run is on screen (the window frames another one, or
|
|
57687
|
+
// the list is scrolled past it): its own first rows are what it will
|
|
57688
|
+
// draw next.
|
|
57689
|
+
start = 0;
|
|
57690
|
+
end = budget - 1;
|
|
57691
|
+
}
|
|
57692
|
+
const firstHeld = pages.byIndex.get(windowFrom);
|
|
57693
|
+
if (firstHeld && firstHeld.id !== undefined) {
|
|
57694
|
+
around = firstHeld.id;
|
|
57695
|
+
}
|
|
57696
|
+
} else if (pages.count === undefined) {
|
|
57227
57697
|
const scrolled = virtual.scrolled;
|
|
57228
57698
|
if (scrolled === "end") {
|
|
57229
57699
|
// Counting back from the end, the way an HTTP range does: a list
|
|
@@ -57254,6 +57724,14 @@ const useItemStore = ({
|
|
|
57254
57724
|
return;
|
|
57255
57725
|
}
|
|
57256
57726
|
const request = requestRef.current;
|
|
57727
|
+
if (revalidating && request.busy) {
|
|
57728
|
+
if (request.revalidating) {
|
|
57729
|
+
return;
|
|
57730
|
+
}
|
|
57731
|
+
// A page for a window that is about to be replaced wholesale.
|
|
57732
|
+
request.controller?.abort();
|
|
57733
|
+
request.busy = false;
|
|
57734
|
+
}
|
|
57257
57735
|
if (request.busy) {
|
|
57258
57736
|
// Still worth waiting for as long as what it went to fetch is still
|
|
57259
57737
|
// what the list would draw. Once it is not, it is called off — and
|
|
@@ -57272,8 +57750,9 @@ const useItemStore = ({
|
|
|
57272
57750
|
}
|
|
57273
57751
|
const held = pages.byIndex.size;
|
|
57274
57752
|
// Asking again for a range that was already asked for, having received
|
|
57275
|
-
// nothing since, can only produce the same answer.
|
|
57276
|
-
|
|
57753
|
+
// nothing since, can only produce the same answer. A revalidation is
|
|
57754
|
+
// exactly the case where it produces another one.
|
|
57755
|
+
if (!revalidating && request.start === start && request.end === end && request.held === held) {
|
|
57277
57756
|
return;
|
|
57278
57757
|
}
|
|
57279
57758
|
request.start = start;
|
|
@@ -57288,17 +57767,33 @@ const useItemStore = ({
|
|
|
57288
57767
|
end,
|
|
57289
57768
|
around,
|
|
57290
57769
|
limit: end - start + 1,
|
|
57291
|
-
|
|
57292
|
-
|
|
57770
|
+
// A cursor names a row of the collection as it was; a revalidation
|
|
57771
|
+
// is asked precisely because that is what changed.
|
|
57772
|
+
before: revalidating ? undefined : cursor.before,
|
|
57773
|
+
after: revalidating ? undefined : cursor.after,
|
|
57293
57774
|
count: pages.count,
|
|
57294
57775
|
signal: controller.signal
|
|
57295
57776
|
};
|
|
57296
57777
|
request.busy = true;
|
|
57778
|
+
request.revalidating = revalidating;
|
|
57779
|
+
if (revalidating) {
|
|
57780
|
+
setRefreshing(true);
|
|
57781
|
+
}
|
|
57297
57782
|
const done = page => {
|
|
57298
57783
|
const current = generation === request.generation;
|
|
57299
57784
|
if (current) {
|
|
57300
57785
|
request.busy = false;
|
|
57786
|
+
request.revalidating = false;
|
|
57301
57787
|
setFailure(null);
|
|
57788
|
+
if (revalidating) {
|
|
57789
|
+
staleRef.current = false;
|
|
57790
|
+
setRefreshing(false);
|
|
57791
|
+
}
|
|
57792
|
+
}
|
|
57793
|
+
if (revalidating && !current) {
|
|
57794
|
+
// Rows of a composition already superseded by a newer ask: keeping
|
|
57795
|
+
// them would mix two states of the collection.
|
|
57796
|
+
return;
|
|
57302
57797
|
}
|
|
57303
57798
|
if (!page) {
|
|
57304
57799
|
return;
|
|
@@ -57309,6 +57804,12 @@ const useItemStore = ({
|
|
|
57309
57804
|
// Before the rows land: what is on screen has to stay where it is,
|
|
57310
57805
|
// and the DOM still shows the state to hold onto.
|
|
57311
57806
|
virtual.captureAnchor();
|
|
57807
|
+
if (revalidating) {
|
|
57808
|
+
// The rows held stood for a composition that has moved on; the
|
|
57809
|
+
// ones outside the window are forgotten and asked for again if the
|
|
57810
|
+
// user goes back to them.
|
|
57811
|
+
pages.byIndex = new Map();
|
|
57812
|
+
}
|
|
57312
57813
|
let i = 0;
|
|
57313
57814
|
while (i < pageItems.length) {
|
|
57314
57815
|
pages.byIndex.set(pageStart + i, pageItems[i]);
|
|
@@ -57324,6 +57825,14 @@ const useItemStore = ({
|
|
|
57324
57825
|
return;
|
|
57325
57826
|
}
|
|
57326
57827
|
request.busy = false;
|
|
57828
|
+
request.revalidating = false;
|
|
57829
|
+
if (revalidating) {
|
|
57830
|
+
// The rows from before stay: a revalidation that failed has
|
|
57831
|
+
// nothing better to put in their place.
|
|
57832
|
+
staleRef.current = false;
|
|
57833
|
+
setRefreshing(false);
|
|
57834
|
+
return;
|
|
57835
|
+
}
|
|
57327
57836
|
setFailure({
|
|
57328
57837
|
start,
|
|
57329
57838
|
end,
|
|
@@ -57928,8 +58437,8 @@ const PickerTypeResolver = props => {
|
|
|
57928
58437
|
...props
|
|
57929
58438
|
});
|
|
57930
58439
|
}
|
|
57931
|
-
if (props.type === "
|
|
57932
|
-
return jsx(
|
|
58440
|
+
if (props.type === "object") {
|
|
58441
|
+
return jsx(PickerObject, {
|
|
57933
58442
|
...props
|
|
57934
58443
|
});
|
|
57935
58444
|
}
|
|
@@ -57945,19 +58454,19 @@ const PickerText = props => {
|
|
|
57945
58454
|
});
|
|
57946
58455
|
};
|
|
57947
58456
|
|
|
57948
|
-
// The popup holds a group of named controls — a `<
|
|
57949
|
-
// when
|
|
57950
|
-
//
|
|
57951
|
-
// surface (see dialog.jsx), so there is nothing to tell it about the shape.
|
|
57952
|
-
const
|
|
58457
|
+
// The popup holds a group of named controls — a `<ControlGroup>`, or a `<Form>`
|
|
58458
|
+
// when that group is a question with a send of its own — and this picker's
|
|
58459
|
+
// value is the object that group aggregates. The popup itself holds nothing: it
|
|
58460
|
+
// is a surface (see dialog.jsx), so there is nothing to tell it about the shape.
|
|
58461
|
+
const PickerObject = props => {
|
|
57953
58462
|
const Next = useNextResolver();
|
|
57954
58463
|
return jsx(Next, {
|
|
57955
|
-
ui: jsx(
|
|
58464
|
+
ui: jsx(PickerObjectUI, {}),
|
|
57956
58465
|
...props,
|
|
57957
58466
|
type: "navi_js"
|
|
57958
58467
|
});
|
|
57959
58468
|
};
|
|
57960
|
-
const
|
|
58469
|
+
const PickerObjectUI = () => {
|
|
57961
58470
|
const {
|
|
57962
58471
|
value,
|
|
57963
58472
|
placeholder
|
|
@@ -58958,110 +59467,6 @@ const PickerFirstResolver = props => {
|
|
|
58958
59467
|
...props
|
|
58959
59468
|
});
|
|
58960
59469
|
};
|
|
58961
|
-
|
|
58962
|
-
/**
|
|
58963
|
-
* Button-like trigger that opens a picker (native or custom popup) when clicked.
|
|
58964
|
-
*
|
|
58965
|
-
* Without `children`, opens the browser-native picker for the given `type`.
|
|
58966
|
-
* With `children`, opens a popover (desktop) or dialog (mobile) containing the children.
|
|
58967
|
-
* Pass `mode="popover"` or `mode="dialog"` to override the automatic choice.
|
|
58968
|
-
*
|
|
58969
|
-
* @type {import("ignore:preact").FunctionComponent<{
|
|
58970
|
-
* type?: "date" | "month" | "week" | "time" | "datetime" | "color" | "hour" | "navi_time" | "navi_number" | "navi_percentage",
|
|
58971
|
-
* value?: any,
|
|
58972
|
-
* defaultValue?: any,
|
|
58973
|
-
* name?: string,
|
|
58974
|
-
* placeholder?: import("ignore:preact").ComponentChildren,
|
|
58975
|
-
* required?: boolean,
|
|
58976
|
-
* min?: Date | string | number,
|
|
58977
|
-
* max?: Date | string | number,
|
|
58978
|
-
* step?: string | number,
|
|
58979
|
-
* disabled?: boolean,
|
|
58980
|
-
* readOnly?: boolean,
|
|
58981
|
-
* error?: boolean | string,
|
|
58982
|
-
* uiAction?: (value: any, event: Event) => void,
|
|
58983
|
-
* action?: (value: any, event: Event) => void,
|
|
58984
|
-
* children?: import("ignore:preact").ComponentChildren,
|
|
58985
|
-
* mode?: "popover" | "dialog",
|
|
58986
|
-
* popoverMode?: "nearby" | "overlay",
|
|
58987
|
-
* positionArea?: string,
|
|
58988
|
-
* popupWidthFitContent?: boolean,
|
|
58989
|
-
* variant?: "icon" | "headless" | "discrete",
|
|
58990
|
-
* rightSlotIcon?: import("ignore:preact").ComponentChildren,
|
|
58991
|
-
* rightSlotIconSize?: number | string,
|
|
58992
|
-
* maxLines?: number,
|
|
58993
|
-
* slotSpacing?: number | string,
|
|
58994
|
-
* popoverMaxHeight?: number | string,
|
|
58995
|
-
* dialogMaxWidth?: number | string,
|
|
58996
|
-
* dialogMaxHeight?: number | string,
|
|
58997
|
-
* popupBackgroundColor?: string,
|
|
58998
|
-
* popupBorderRadius?: number | string,
|
|
58999
|
-
* clearable?: boolean,
|
|
59000
|
-
* popupLayer?: "top" | "local",
|
|
59001
|
-
* dialogExpand?: boolean,
|
|
59002
|
-
* dialogExpandX?: boolean,
|
|
59003
|
-
* dialogExpandY?: boolean,
|
|
59004
|
-
* marginWithContainer?: number | string,
|
|
59005
|
-
* escapeEffect?: "cancel" | "close",
|
|
59006
|
-
* pointerInteractionOutsideEffect?: "close" | "cancel" | "capture",
|
|
59007
|
-
* ref?: import("ignore:preact").RefObject<HTMLElement>,
|
|
59008
|
-
* [key: string]: any,
|
|
59009
|
-
* }>}
|
|
59010
|
-
* @param {boolean|string} [error] Something went wrong around this picker (its
|
|
59011
|
-
* content failed to load, its value could not be resolved…). Shown as a
|
|
59012
|
-
* callout on the trigger, open or closed — the caller has nothing to place.
|
|
59013
|
-
* Dismissing it discards that error; a new `error` value raises another one.
|
|
59014
|
-
* @param {"nearby"|"overlay"} [popoverMode="nearby"] "overlay" lays the popover
|
|
59015
|
-
* over the trigger, "nearby" leaves a small gap below it.
|
|
59016
|
-
* @param {string} [positionArea] Where the popup goes — relative to the trigger
|
|
59017
|
-
* in popover mode, relative to the viewport in dialog mode. Same grammar as
|
|
59018
|
-
* Popover/Dialog's own `positionArea` ("top", "right-end", "inset(top-left)",
|
|
59019
|
-
* …). Defaults to "bottom-start" in popover mode ("inset(top-left)" when
|
|
59020
|
-
* popoverMode is "overlay"), and to Dialog's own "center" in dialog mode. A
|
|
59021
|
-
* popover still flips to the opposite side on its own when there isn't
|
|
59022
|
-
* enough room.
|
|
59023
|
-
* @param {boolean} [popupWidthFitContent] By default the popup is at least as
|
|
59024
|
-
* wide as the trigger. Set this to let the content size it instead, so a
|
|
59025
|
-
* popup narrower than the trigger stays narrow.
|
|
59026
|
-
* @param {import("ignore:preact").ComponentChildren} [rightSlotIcon] What the right
|
|
59027
|
-
* slot draws in place of the chevron. It is the whole slot, not an addition
|
|
59028
|
-
* to it: the picker then no longer says on its own that it opens, so pass
|
|
59029
|
-
* something that does.
|
|
59030
|
-
* @param {number|string} [rightSlotIconSize="inherit"] How big what sits in the
|
|
59031
|
-
* right slot is drawn — the chevron, a `rightSlotIcon`, or the clear button's
|
|
59032
|
-
* cross. "inherit" takes the picker's own font size.
|
|
59033
|
-
* @param {number|string} [slotSpacing] Gap kept between what sits in the right
|
|
59034
|
-
* slot (the chevron, or the clear button) and the picker's own edge — same
|
|
59035
|
-
* prop name Input uses for its own slots. Accepts a spacing token ("s",
|
|
59036
|
-
* "m"…) like any other spacing prop, or a length. Defaults to half the
|
|
59037
|
-
* horizontal padding.
|
|
59038
|
-
* @param {number|string} [popoverMaxHeight] Soft cap on the popover's height
|
|
59039
|
-
* (default 300px). The popover shrinks below it when space is tight.
|
|
59040
|
-
* @param {number|string} [dialogMaxWidth] Ceiling on the dialog's width, the
|
|
59041
|
-
* one `dialogExpand`/`dialogExpandX` grows up to — what makes an expanded
|
|
59042
|
-
* dialog a large sheet rather than a full screen. Capped in turn by the
|
|
59043
|
-
* container minus `marginWithContainer`.
|
|
59044
|
-
* @param {number|string} [dialogMaxHeight] Same, on the height.
|
|
59045
|
-
* @param {"cancel"|"close"} [escapeEffect="cancel"] What Escape does to an open
|
|
59046
|
-
* picker. "cancel" puts back the value the picker had at open, and a dialog
|
|
59047
|
-
* picker also goes back in history — so anything written to the url while it
|
|
59048
|
-
* was open (a route `stateSignal`, a search param) goes back with it. "close"
|
|
59049
|
-
* makes Escape say what clicking outside says: keep what was chosen, close —
|
|
59050
|
-
* a last resort, see docs/popup_open.md ("Escape cancels, the other gestures
|
|
59051
|
-
* keep") for why Escape should go on meaning cancel, and for what the value
|
|
59052
|
-
* at open is on the picker's very first open.
|
|
59053
|
-
* @param {"close"|"cancel"|"capture"} [pointerInteractionOutsideEffect="close"]
|
|
59054
|
-
* What a click outside the popup does: close and keep ("close"), close and
|
|
59055
|
-
* put back the value at open ("cancel"), or nothing at all ("capture"). The
|
|
59056
|
-
* default is what gives a popup with no confirm button its way out that
|
|
59057
|
-
* keeps — see the same section.
|
|
59058
|
-
* @param {number|string} [marginWithContainer] Minimum gap kept between the
|
|
59059
|
-
* popup and the edges of what contains it (the viewport, or the picker's own
|
|
59060
|
-
* positioned ancestor for `popupLayer="local"`). Caps the popup's size as
|
|
59061
|
-
* well as its placement, so what an expanded dialog leaves visible around
|
|
59062
|
-
* itself is set here. Defaults to `popoverSpacing` in popover mode, and to
|
|
59063
|
-
* Dialog's own 3vvw in dialog mode.
|
|
59064
|
-
*/
|
|
59065
59470
|
const Picker = createComponentResolver([PickerFirstResolver, PickerPresetResolver, PickerCustomResolver, PickerTypeResolver, PickerButton]);
|
|
59066
59471
|
Picker.UI = PickerDefaultUI;
|
|
59067
59472
|
Picker.UI.Date = PickerDateUI;
|
|
@@ -59071,7 +59476,7 @@ Picker.UI.Week = PickerWeekUI;
|
|
|
59071
59476
|
Picker.UI.Datetime = PickerDatetimeUI;
|
|
59072
59477
|
Picker.UI.File = PickerFileUI;
|
|
59073
59478
|
Picker.UI.Color = PickerColorUI;
|
|
59074
|
-
Picker.UI.
|
|
59479
|
+
Picker.UI.Object = PickerObjectUI;
|
|
59075
59480
|
Picker.UI.Multiple = PickerArrayUI;
|
|
59076
59481
|
Picker.UI.PencilSvg = PencilSvg;
|
|
59077
59482
|
Picker.UI.ChevronDownSvg = ChevronDownSvg$1;
|