Skip to content

Commit 2f65436

Browse files
committed
work
1 parent b2ae50f commit 2f65436

10 files changed

Lines changed: 116 additions & 10 deletions

File tree

packages/frontend/navi/docs/AI_INSTRUCTIONS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,9 @@ to start when unsure which export solves a problem.
166166

167167
- `interactions.md` — a gesture is named, not read by hand: the `interactions`
168168
prop, swipes and holds, the gate, `selfInteractions` for an affordance inside
169-
somebody else's box, registering a detector. Read before a `pointerdown`
170-
listener of your own, and before stopping propagation to keep a popup shut.
169+
somebody else's box, `actionStandalone` for a run nothing above waits on,
170+
registering a detector. Read before a `pointerdown` listener of your own, and
171+
before stopping propagation to keep a popup shut.
171172
- `drag_interactions.md` — an element carried: `move`, `reorder`, `land`,
172173
`toss`, `leave`, `moving`, the `grab`/`release`/`refuse` moments, dressing the
173174
clone, and the machinery for a gesture whose product is a value. Read before

packages/frontend/navi/docs/form_changed.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,9 @@ and that is deliberate: a door that carries no value of its own can still
181181
write into the form through what its popup does, and a read-only form must
182182
still shut it. An affordance that genuinely writes nowhere says that
183183
separately, with `whenSelfInteractionsBlocked="ignore"` — as it says whose
184-
press it is with `selfInteractions`. Three questions, three props (see
184+
press it is with `selfInteractions`, and a control whose action is meant to be
185+
left running says THAT separately too, with `actionStandalone`. One question
186+
each, one prop each (see
185187
[interactions.md](./interactions.md#the-third-question-whose-value-is-it)).
186188

187189
Every control takes the prop, groups included — `<Form standalone>` is the

packages/frontend/navi/docs/interactions.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- [Why it is a list, and why it is required](#why-it-is-a-list-and-why-it-is-required)
1313
- [Where the zone blocks: does it write to the control it sits in?](#where-the-zone-blocks-does-it-write-to-the-control-it-sits-in)
1414
- [The third question: whose value is it?](#the-third-question-whose-value-is-it)
15+
- [The fourth question: whose wait is it?](#the-fourth-question-whose-wait-is-it)
1516
- [On something you draw yourself](#on-something-you-draw-yourself)
1617
- [When the affordance should sit OUTSIDE instead](#when-the-affordance-should-sit-outside-instead)
1718
- [navi steps back; a plain `onClick` does not](#navi-steps-back-a-plain-onclick-does-not)
@@ -278,7 +279,7 @@ that gesture. Whose **value** an element carries is a separate question, and
278279
group around it, so what it holds never joins that value (see
279280
[form_changed.md](./form_changed.md#a-control-that-answers-for-itself)).
280281

281-
The three come apart, which is why they are three props:
282+
They come apart, which is why they are separate props:
282283

283284
| the element | says |
284285
| ------------------------------------------------------- | --------------------------------------------------------------------------- |
@@ -291,6 +292,39 @@ Reading `standalone` as "ignore everything around me" is the trap: `disabled`,
291292
because "what do I hold" and "may anything be changed here" are not the same
292293
question.
293294

295+
### The fourth question: whose wait is it?
296+
297+
A running action says two things at once. To the control: I am mid-action —
298+
busy, a second press refused, the error callout if it fails. To everything
299+
around it: nothing here moves on — the form does not submit, and the popup does
300+
not close (see
301+
[popup_open.md](./popup_open.md#the-popup-owns-its-open-state)).
302+
303+
The second half is a promise about an answer: a send holds something neither
304+
committed nor given up, so the screen showing it stays. It is exactly wrong for
305+
a run that was started to be LEFT running — activating a service worker update,
306+
which lands only once the browser switches over and can be held by the page's
307+
own in-flight work for minutes. Waiting is not the point; the app goes on being
308+
used and the feedback is somewhere else entirely. `actionStandalone` says the
309+
wait is the control's own:
310+
311+
```jsx
312+
<Button action={() => activateUpdate()} actionStandalone>
313+
Activate
314+
</Button>
315+
```
316+
317+
Everything the control does for itself stays: it renders busy, it refuses a
318+
second press, and it raises the error callout — which is why it keeps `action`
319+
rather than re-implementing the three by hand. What changes is that no ancestor
320+
is told: the form around it submits, and the panel it sits in closes on Escape,
321+
on the backdrop and on its cross.
322+
323+
The question that picks it is what closing over the run would lose. An answer
324+
being sent: everything — the popup is the only place its failure can be read,
325+
and `actionStandalone` there is how a save fails behind a closed popup. Something
326+
the app watches from somewhere else: nothing — it was never being watched here.
327+
294328
### On something you draw yourself
295329

296330
`selfInteractions` is a `Box` prop too, so an affordance does not have to become

packages/frontend/navi/docs/popup_open.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,23 @@ A form that is sending holds an answer that is neither committed nor given up.
4141
Escape, the backdrop, a close button — all of them ask, and the busy control
4242
answers, the same way it would answer anyone else.
4343

44+
That is the right answer for a run bounded by a request that comes back. A run
45+
deliberately left going is the other case — activating a service worker update,
46+
which lands only once the browser switches over and can take as long as the
47+
page's own in-flight work takes. Nothing is held there: the app stays usable
48+
while it settles, and a panel that waits for it is a panel that may never close
49+
again. The control says so, and no popup around it is told:
50+
51+
```jsx
52+
<Button action={() => activateUpdate()} actionStandalone>
53+
Activate
54+
</Button>
55+
```
56+
57+
It is still busy for itself — the spinner, the second press refused, the error
58+
callout — which is the whole point of keeping `action` (see
59+
[interactions.md](./interactions.md#the-fourth-question-whose-wait-is-it)).
60+
4461
So the question is never "should this popup be controlled?" but "what triggers
4562
the opening?" — and, when the answer is the application rather than a gesture,
4663
where that state lives:
@@ -497,6 +514,10 @@ save running on its own behind a closed popup — close from somewhere the actio
497514
does not hold up, e.g. an `onClick` of your own. Know what it costs: **a save
498515
that fails does so behind a closed popup**.
499516

517+
Nobody wanting to close, but everybody wanting to be ABLE to, is a different
518+
need and has its own answer: `actionStandalone` (see the top of this page) frees
519+
the popup without closing it, and the error callout stays where the press was.
520+
500521
## Escape cancels, the other gestures keep
501522

502523
The gestures that close a popup do not all mean the same thing, and that is on

packages/frontend/navi/src/control/control_context.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ export const CONTROL_PROP_SET = new Set([
117117
"resetOnAbort",
118118
"resetOnError",
119119
"optimistic",
120+
// The wait this control's action creates is its own: it renders busy and
121+
// refuses a second press, and nothing above it is told (see BUSY_CONSTRAINT).
122+
"actionStandalone",
120123

121124
"charGuard",
122125
"maxLengthGuard",

packages/frontend/navi/src/control/control_hooks.jsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1593,7 +1593,14 @@ const useInteractiveProps = (
15931593
const parentAction = useContext(ActionContext);
15941594
const actionStatus = useActionStatus(boundAction);
15951595
const networkPolicyReason = useNetworkPolicyReason();
1596-
const { disabled, required, readOnly, loading, optimistic } = props;
1596+
const {
1597+
disabled,
1598+
required,
1599+
readOnly,
1600+
loading,
1601+
optimistic,
1602+
actionStandalone,
1603+
} = props;
15971604

15981605
// `whenSelfInteractionsBlocked="ignore"`: an affordance that writes nothing
15991606
// to the control it sits in has no business inheriting that control's state
@@ -1676,6 +1683,11 @@ const useInteractiveProps = (
16761683
// Read by BUSY_CONSTRAINT: an optimistic control stays interactive while
16771684
// its bound action runs (a new toggle replaces the run instead of waiting).
16781685
uiStateController.optimistic = Boolean(optimistic);
1686+
// Read by BUSY_CONSTRAINT: the wait belongs to this control alone. It is
1687+
// busy for itself — the render, the second press, the callout — and nothing
1688+
// above it is told, so a form still submits and a popup still closes over a
1689+
// run that was meant to be left going.
1690+
uiStateController.actionStandalone = Boolean(actionStandalone);
16791691
// What the interaction rule last refused is only true while the control is
16801692
// held; the state it was read from moves here (see refreshReport).
16811693
useLayoutEffect(() => {

packages/frontend/navi/src/control/input/button.jsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
124124
* selfInteractions?: string,
125125
* whenSelfInteractionsBlocked?: "hide" | "refuse" | "ignore",
126126
* replace?: boolean,
127+
* actionStandalone?: boolean,
127128
* [key: string]: any,
128129
* }>}
129130
* @param {boolean} [replace] Go where the press leads — an `href`, a
@@ -154,6 +155,13 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
154155
* by whether it WRITES to the control it sits in: it goes (`"hide"`, the
155156
* default), `"refuse"` keeps it and refuses with a callout, `"ignore"` lets
156157
* it through untouched — for an affordance that never wrote to that control.
158+
* @param {boolean} [actionStandalone] The wait this button's action creates is
159+
* its own: it renders busy, refuses a second press and raises the error
160+
* callout as always, and nothing above it is told — the form around it still
161+
* submits, the popup it sits in still closes. For a run started to be left
162+
* running, which the app watches from somewhere else; never for one holding
163+
* an answer the screen is the only place to read (see
164+
* docs/interactions.md#the-fourth-question-whose-wait-is-it).
157165
* @param {string} [contentDisplay] The display of the frame the button draws
158166
* around its children. It follows the button's own by default — its display
159167
* and, a display alone saying nothing about direction, the rest of its flow

packages/frontend/navi/src/control/rules/interaction/busy_constraint.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export const BUSY_CONSTRAINT = {
1313
transient: true,
1414
// Unlike readonly/disabled, a busy element DOES block its parent from
1515
// submitting — the element is mid-operation and cannot safely participate.
16+
// Unless it says the wait is its own (`actionStandalone`): then the refusal
17+
// stays on the element and every ancestor reads it as free — the group above
18+
// (see getInteractionBlockingControls) and the popup around it (see
19+
// findBusyElementInside in dialog.jsx and popover.jsx, which both filter on
20+
// `ignoredByParents`).
1621
check: (field, { intent } = {}) => {
1722
const isBusy = isControlBusy(field);
1823
if (!isBusy) {
@@ -32,7 +37,11 @@ export const BUSY_CONSTRAINT = {
3237
const message = isButton
3338
? naviI18n("constraint.busy.button")
3439
: naviI18n("constraint.busy.default");
35-
return { message, status: "info" };
40+
return {
41+
message,
42+
status: "info",
43+
ignoredByParents: Boolean(field.actionStandalone),
44+
};
3645
},
3746
};
3847
CONSTRAINT_ATTRIBUTE_SET.add("data-busy");

packages/frontend/navi/src/layout/dialog.jsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -970,10 +970,19 @@ const DOCKED_SWIPE_GRIP = "[data-header],[data-swipe-grip]";
970970
// controls rather than reading an attribute off the dialog: a dialog carries no
971971
// state of its own (see this file's top comment), and `aria-busy` on the
972972
// controls is a render snapshot — BUSY_CONSTRAINT reads the live answer.
973+
// Same as Popover's own; kept in both rather than shared, since each file reads
974+
// on its own — what changes here changes there too.
973975
const findBusyElementInside = (dialogEl) => {
974976
for (const element of dialogEl.querySelectorAll("[navi-control-host]")) {
975977
const controller = element.__uiStateController__;
976-
if (controller && BUSY_CONSTRAINT.check(controller)) {
978+
if (!controller) {
979+
continue;
980+
}
981+
const busyInfo = BUSY_CONSTRAINT.check(controller);
982+
// `ignoredByParents`: the control says the wait is its own
983+
// (`actionStandalone`), so a popup is one more ancestor it does not hold —
984+
// the same reading a group makes of it (see control_interaction.js).
985+
if (busyInfo && !busyInfo.ignoredByParents) {
977986
return element;
978987
}
979988
}

packages/frontend/navi/src/layout/popover.jsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -767,12 +767,19 @@ const PopoverCustom = (props) => {
767767
// controls rather than reading an attribute off the popup: a popup carries no
768768
// state of its own (see this file's top comment), and `aria-busy` on the
769769
// controls is a render snapshot — BUSY_CONSTRAINT reads the live answer.
770-
// Same as Dialog's own; kept in both rather than shared, since it is three
771-
// lines and each file reads on its own.
770+
// Same as Dialog's own; kept in both rather than shared, since each file reads
771+
// on its own — what changes here changes there too.
772772
const findBusyElementInside = (popupEl) => {
773773
for (const element of popupEl.querySelectorAll("[navi-control-host]")) {
774774
const controller = element.__uiStateController__;
775-
if (controller && BUSY_CONSTRAINT.check(controller)) {
775+
if (!controller) {
776+
continue;
777+
}
778+
const busyInfo = BUSY_CONSTRAINT.check(controller);
779+
// `ignoredByParents`: the control says the wait is its own
780+
// (`actionStandalone`), so a popup is one more ancestor it does not hold —
781+
// the same reading a group makes of it (see control_interaction.js).
782+
if (busyInfo && !busyInfo.ignoredByParents) {
776783
return element;
777784
}
778785
}

0 commit comments

Comments
 (0)