@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.
- package/README.md +2 -0
- package/dist/jsenv_navi.js +443 -158
- package/dist/jsenv_navi.js.map +25 -13
- package/docs/popup_open.md +185 -0
- package/package.json +1 -1
|
@@ -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.
|