@jsenv/navi 0.29.23 → 0.29.25

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.
@@ -0,0 +1,185 @@
1
+ # Opening a popup
2
+
3
+ What opens a `Dialog` or a `Popover`, and who owns the fact that it is open.
4
+
5
+ - [The popup owns its open state](#the-popup-owns-its-open-state)
6
+ - [A button opens it: the attributes](#a-button-opens-it-the-attributes)
7
+ - [Something else opens it: `triggerNaviCommand`](#something-else-opens-it-triggernavicommand)
8
+ - [Which element receives the command](#which-element-receives-the-command)
9
+ - [The anchor](#the-anchor)
10
+ - [Reacting to open and close](#reacting-to-open-and-close)
11
+ - [When `open` is the right answer, and what it costs](#when-open-is-the-right-answer-and-what-it-costs)
12
+ - [What the popup holds while it is closed](#what-the-popup-holds-while-it-is-closed)
13
+
14
+ ## The popup owns its open state
15
+
16
+ A `Dialog`/`Popover` with no `open` prop keeps its own open state and listens
17
+ for requests to change it. That is the default way to use one, and it buys
18
+ something a `useState` in the parent cannot give back: **a popup refuses to
19
+ close while a control inside it is mid-action**.
20
+
21
+ ```js
22
+ // what both do on every close request
23
+ const busyElement = findBusyElementInside(popupEl);
24
+ if (busyElement) {
25
+ dispatchRequestInteraction(busyElement, { ... });
26
+ requestCloseEvent.preventDefault();
27
+ }
28
+ ```
29
+
30
+ A form that is sending holds an answer that is neither committed nor given up.
31
+ Escape, the backdrop, a close button — all of them ask, and the busy control
32
+ answers, the same way it would answer anyone else.
33
+
34
+ So the question is never "should this popup be controlled?" but "what triggers
35
+ the opening?":
36
+
37
+ | what opens it | how |
38
+ | ---------------------------------- | ---------------------------------------------- |
39
+ | a button | `command` / `commandfor` attributes, no `open` |
40
+ | a gesture, an event, a JS decision | `triggerNaviCommand(...)`, still no `open` |
41
+ | it is a piece of application state | `open` — see the cost below |
42
+
43
+ ## A button opens it: the attributes
44
+
45
+ ```jsx
46
+ <Button command="--navi-open" commandfor="note-dialog">
47
+ Read the note
48
+ </Button>
49
+ <Dialog id="note-dialog">…</Dialog>
50
+ ```
51
+
52
+ The available commands: `--navi-open`, `--navi-close`, `--navi-toggle`,
53
+ `--navi-cancel` (closes, telling the popup the close means "revert"),
54
+ `--navi-confirm` (says yes, then closes).
55
+
56
+ ## Something else opens it: `triggerNaviCommand`
57
+
58
+ The attributes fire on every click of the element that carries them. As soon as
59
+ the opening is a decision rather than a click — a long press, the end of a drag,
60
+ a double-click, a keyboard shortcut, a server answer, an `IntersectionObserver` —
61
+ the decision has to be made in JS, and the command triggered from there:
62
+
63
+ ```jsx
64
+ import { triggerNaviCommand } from "@jsenv/navi";
65
+
66
+ const dialogRef = useRef(null);
67
+ const open = (event) => {
68
+ if (draggedRef.current) {
69
+ // the click that ends a throw is not a request to open
70
+ return;
71
+ }
72
+ triggerNaviCommand(dialogRef.current, "--navi-open", event);
73
+ };
74
+
75
+ <Box role="button" onClick={open}>…</Box>
76
+ <Dialog ref={dialogRef}>…</Dialog>
77
+ ```
78
+
79
+ This is the same entry point the attributes go through: same target resolution,
80
+ same command proxies, same events. The popup stays uncontrolled, and keeps its
81
+ say over closing.
82
+
83
+ `event` is the DOM event the decision came from. Pass it whenever there is one:
84
+ it is chained into the request event, and that chain is what lets the popup
85
+ handle focus correctly (which element to give focus back to, whether a
86
+ mousedown's click must be swallowed). Omit it only when nothing user-initiated
87
+ triggered the command.
88
+
89
+ ## Which element receives the command
90
+
91
+ The first argument is the command's **source** — the element it is triggered
92
+ _from_. The target is resolved from it, in this order:
93
+
94
+ 1. `commandfor="someId"` on the source,
95
+ 2. `navi-command-target="parent-control" | "child-control"`,
96
+ 3. the command's own fallback — for the popup commands, `closest("[aria-expanded]")`.
97
+
98
+ A popup carries `aria-expanded` from its very first render, so passing the popup
99
+ element itself as the source resolves to that popup: `closest()` starts at the
100
+ element itself. That is the short form used above, and it is enough whenever the
101
+ JS that decides already holds the popup's ref.
102
+
103
+ To trigger from another element instead, give that element a `commandfor`
104
+ pointing at the popup's `id` — attribute-driven target resolution, JS-driven
105
+ timing.
106
+
107
+ ## The anchor
108
+
109
+ A popup with no `anchor` prop uses the command's source as its anchor
110
+ (`detail.anchor ?? detail.source`). Passing the popup itself as the source
111
+ therefore makes it its own anchor. For `Dialog` this only affects the
112
+ `--anchor-width`/`--anchor-height` CSS vars; for `Popover`, which really is
113
+ positioned relative to its anchor, say what the anchor is:
114
+
115
+ ```jsx
116
+ <Popover ref={popoverRef} anchor={rowRef}>
117
+ ```
118
+
119
+ The `anchor` prop always wins over whatever the command carried.
120
+ `anchorCustomEventDetail="ignore"` (Popover only) goes further and drops the
121
+ event's anchor entirely, for a popover that must never be anchored to whatever
122
+ opened it.
123
+
124
+ ## Reacting to open and close
125
+
126
+ `onClose` is called on every real close. There is no `onOpen`: an uncontrolled
127
+ popup rewrites its own open handler, so a passed one would never run. Listen on
128
+ the ref instead — or, when the JS that opens is yours, do the work right where
129
+ you trigger the command.
130
+
131
+ ```js
132
+ useLayoutEffect(() => {
133
+ const dialog = dialogRef.current;
134
+ const onOpen = () => {
135
+ /* … */
136
+ };
137
+ dialog.addEventListener("navi_request_open", onOpen);
138
+ return () => dialog.removeEventListener("navi_request_open", onOpen);
139
+ }, []);
140
+ ```
141
+
142
+ ## When `open` is the right answer, and what it costs
143
+
144
+ `open` is for a popup whose being-open is a fact about the application, not
145
+ about the user's last gesture: a route that _is_ a dialog, an error the app
146
+ decides to show. Use it there, and know what it changes.
147
+
148
+ The busy arbitration still runs — `open={false}` goes through the same
149
+ `requestClose` — but nobody hears the refusal. The parent's state says closed,
150
+ the popup stayed open, and the two disagree from then on: the effect only reacts
151
+ to `open` _changing_, so setting it back to `true` matches the popup's real
152
+ state and does nothing, and the popup can no longer be closed by the prop at
153
+ all until it closes on its own.
154
+
155
+ `defaultOpen` is the middle ground: mount-only, and the popup owns everything
156
+ afterwards. `defaultOpen="interaction"` means the mount _is_ the opening (the
157
+ entrance animation plays); any other truthy value means it was already open when
158
+ the page appeared (no entrance).
159
+
160
+ ## What the popup holds while it is closed
161
+
162
+ A closed popup builds nothing: `children` are mounted on the first open, and
163
+ stay mounted afterwards — a reopened popup finds its scroll position and its
164
+ half-typed form where it left them.
165
+
166
+ Two props move that line:
167
+
168
+ | prop | effect |
169
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
170
+ | `mountWhenClosed` | build `children` right away — for content something depends on before any opening (a value read off it, fields a surrounding form submits, a size measured from outside) |
171
+ | `unmountWhenClosed` | throw `children` away once the popup has finished closing — for content whose fresh state is its initial state |
172
+
173
+ `unmountWhenClosed` is what an uncontrolled field seeded from a `defaultValue`
174
+ needs: without it, a popup reopened after the underlying value changed still
175
+ shows what it showed at closing time.
176
+
177
+ ```jsx
178
+ <Dialog ref={dialogRef} unmountWhenClosed>
179
+ <Textarea defaultValue={note.text} />
180
+ </Dialog>
181
+ ```
182
+
183
+ The content is dropped only once the exit transition is over, so the popup never
184
+ plays it on a blank surface; a popup reopened while it was leaving keeps the
185
+ content that opening just asked for. `mountWhenClosed` wins if both are set.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.23",
3
+ "version": "0.29.25",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {