@jsenv/navi 0.29.146 → 0.29.147

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.
@@ -139,13 +139,18 @@ consistency across the app, not from any single call site.
139
139
  `expandX={false}` to stop a dialog sprawling, and before writing CSS to make a
140
140
  dialog fit the screen.
141
141
  - `docs/autofocus.md` — who gets the keyboard when a popup opens or a slide
142
- arrives: the ladder navi walks, what `autoFocus` means on a surface (`true`
143
- = the surface takes it, for a popup that is READ before it is filled) versus
144
- on a field, why a docked dialog keeps the keyboard down on a phone, and what
145
- happens when the opening finds nothing to focus. Read it before wondering
146
- where the focus went when a popup opened, and before removing `autoFocus`
147
- from fields to stop a virtual keyboard rising — the surface is what decides,
148
- not the absence of a field asking.
142
+ arrives, and whether a ring shows on it: the ladder navi walks, what
143
+ `autoFocus` means on a surface (`true` = the surface takes it, for a popup
144
+ that is READ before it is filled) versus on a field, why a docked dialog
145
+ keeps the keyboard down on a phone, what happens when the opening finds
146
+ nothing to focus, and the rule deciding the focus ring — the modality of what
147
+ asked for the transfer, an editable target ringing whatever asked. Read it
148
+ before wondering where the focus went when a popup opened, before removing
149
+ `autoFocus` from fields to stop a virtual keyboard rising (the surface is
150
+ what decides, not the absence of a field asking), and before calling
151
+ `element.focus()` anywhere — `moveFocusTo(element)` is the exported helper
152
+ that applies both decisions, and a bare `focus()` leaves the ring to the
153
+ browser.
149
154
  - `docs/scroll.md` — where scrolling happens: what turns `Box`
150
155
  `header`/`body`/`footer` on, `FixedBar` space, `List`'s `scroller`, scroll
151
156
  inside a `Dialog`/`Popover`, and what a scroll does to hover
package/docs/actions.md CHANGED
@@ -123,6 +123,10 @@ in one would be waiting for itself. Everything else is unchanged — `loading` a
123
123
  when asked for — and running an action twice costs nothing, so nothing has to
124
124
  be guarded.
125
125
 
126
+ Where that wait is caught is the caller's business, and a popup is no exception
127
+ — see [popup_open.md](./popup_open.md#a-popup-that-loads-data) for what that
128
+ costs when it is forgotten.
129
+
126
130
  **`{ run: true }` is the fallback, not the shape to reach for.** A route action
127
131
  is asked for when the ADDRESS changes, with everything else that address needs;
128
132
  this one cannot start before the component that draws it exists, which is a
package/docs/autofocus.md CHANGED
@@ -1,8 +1,10 @@
1
- # Where the keyboard goes when something opens
1
+ # Where the keyboard goes, and when a ring shows
2
2
 
3
3
  A dialog, a popover, a slide arriving: one of them opens and something inside
4
4
  has to hold the keyboard. `autoFocus` is how each element takes part in that
5
- decision.
5
+ decision. Handing the focus over answers a second question at the same time —
6
+ whether what receives it shows a focus ring — and navi answers that one too,
7
+ at the end of this page.
6
8
 
7
9
  - [What we want](#what-we-want)
8
10
  - [The ladder](#the-ladder)
@@ -12,6 +14,8 @@ decision.
12
14
  - [Opting a field back in](#opting-a-field-back-in)
13
15
  - [What a field says about itself](#what-a-field-says-about-itself)
14
16
  - [When the opening places nothing](#when-the-opening-places-nothing)
17
+ - [The ring is decided too](#the-ring-is-decided-too)
18
+ - [Moving the focus yourself](#moving-the-focus-yourself)
15
19
 
16
20
  ## What we want
17
21
 
@@ -162,3 +166,65 @@ That debt is settled two ways, whichever comes first:
162
166
  Without this, the same popup would land the focus in a different place — or
163
167
  nowhere — depending on whether it was opened by a click or by the page loading,
164
168
  which is the same popup behaving differently for no reason the user can see.
169
+
170
+ ## The ring is decided too
171
+
172
+ Handing the focus over answers a second question: does what receives it show a
173
+ focus ring? The browser's own answer is unusable here — it reads the last thing
174
+ that touched the DOM, and a focus that came from code touched it last. So navi
175
+ answers instead, and the answer is the **modality of what asked**:
176
+
177
+ - the user was on the keyboard when this was asked for — a Tab, an Enter, an
178
+ arrow key: a ring;
179
+ - a finger or a mouse asked: no ring, whatever the code did in between;
180
+ - the element receiving it is **editable** — a text input, a textarea, a
181
+ contenteditable: a ring anyway. Someone about to type has to see where.
182
+
183
+ That last one is why opening a search field with a mouse click still rings: the
184
+ field is where you are about to type, not somewhere you merely landed.
185
+
186
+ Nothing to pass, and nothing to opt into: it is how every navi component moves
187
+ the focus.
188
+
189
+ ### In your CSS
190
+
191
+ `:focus-visible` and `[data-focus-visible]` — the standard selectors, nothing
192
+ navi-specific. navi implements them with a wider meaning than the browser's: an
193
+ element also counts as focused while a **proxy** holds the focus (a read-only
194
+ range delegating to a sibling) or while an element **controlling** it does (a
195
+ combobox input with `aria-controls` on its listbox — the listbox reads as
196
+ focused while the input is). Style the standard name and you get that for free.
197
+
198
+ ```css
199
+ .my_row:focus-visible {
200
+ outline: 2px solid var(--navi-focus-outline-color);
201
+ }
202
+ ```
203
+
204
+ ## Moving the focus yourself
205
+
206
+ Prefer saying where the focus belongs over moving it: `autoFocus` on the field
207
+ or the surface (the ladder above), and navi places it at the right moment, with
208
+ the right ring, without scrolling anything.
209
+
210
+ When you really do have to move it — a control of your own handing over to
211
+ another — use `moveFocusTo` rather than `element.focus()`:
212
+
213
+ ```js
214
+ import { moveFocusTo } from "@jsenv/navi";
215
+
216
+ moveFocusTo(searchInputElement);
217
+ ```
218
+
219
+ That is the whole call. It applies the two decisions above for you: no
220
+ scroll-into-view, and a ring only where the user is on the keyboard (an
221
+ editable target ringing anyway). A bare `element.focus()` answers "who" and
222
+ leaves "is this visible" to the browser, and the browser reads the last thing
223
+ that touched the DOM — which was your own call.
224
+
225
+ Pass `focusVisible` when you know better than the last interaction does, and
226
+ `preventScroll: false` when the element really has to be brought into view:
227
+
228
+ ```js
229
+ moveFocusTo(element, { focusVisible: true });
230
+ ```
@@ -6,6 +6,7 @@ somewhere else in the app.
6
6
 
7
7
  - [The three answers](#the-three-answers)
8
8
  - [A bound signal works in both directions](#a-bound-signal-works-in-both-directions)
9
+ - [A picker fills its popup: the control inside already knows](#a-picker-fills-its-popup-the-control-inside-already-knows)
9
10
  - [A button that proposes a value is `--navi-update`](#a-button-that-proposes-a-value-is---navi-update)
10
11
  - [`--navi-update:smooth`: the control is seen answering](#--navi-updatesmooth-the-control-is-seen-answering)
11
12
  - [`signal` + `defaultValue`: the answer and where it starts](#signal--defaultvalue-the-answer-and-where-it-starts)
@@ -74,6 +75,37 @@ A value pushed in from anywhere is an answer like any other: the wheels roll,
74
75
  and the submit lights up. Which is why a **button** offering such a value is not
75
76
  a hand-written signal write — see the next section.
76
77
 
78
+ ## A picker fills its popup: the control inside already knows
79
+
80
+ A picker mirrors the control in its popup, both ways
81
+ ([popup_open.md](./popup_open.md#composing-a-value-or-doing-work)) — and the
82
+ first half of that mirroring happens **at open**: the picker fills the control
83
+ with the value it holds, before anything is shown.
84
+
85
+ ```jsx
86
+ // the row matching the picker's value is already selected, painted and
87
+ // announced — nothing here says which one it is
88
+ <Picker id="channel" signal={channelSignal}>
89
+ <List selectable>
90
+ {CHANNELS.map((channel) => (
91
+ <List.Item key={channel.id} value={channel.id}>
92
+ {channel.label}
93
+ </List.Item>
94
+ ))}
95
+ </List>
96
+ </Picker>
97
+ ```
98
+
99
+ So a `current`, a `selected` per row, or a `value` computed from what the picker
100
+ holds is not "being explicit": it is a second answer to a question already
101
+ answered, and it is the one that goes stale — the picker's value moves on a
102
+ cancel, on a `--navi-update`, on a signal written elsewhere, and none of those
103
+ pass through the prop the app is computing.
104
+
105
+ What is left for the app is what to make of the selection, not what it is:
106
+ a row is marked (`aria-selected`) and painted by the list itself, so anything
107
+ extra hangs off that marker in CSS rather than off a prop.
108
+
77
109
  ## A button that proposes a value is `--navi-update`
78
110
 
79
111
  A shortcut beside a control — "Tous niveaux" / "Aucun niveau" next to a list of
@@ -552,6 +552,24 @@ const GroupMembers = () => {
552
552
  };
553
553
  ```
554
554
 
555
+ **A popup that waits holds its own `<Loading>`**, like every other part of a
556
+ screen that can wait — it is not built into `Dialog`/`Popover`, because only the
557
+ code inside knows whether anything in there loads at all:
558
+
559
+ ```jsx
560
+ <Dialog signal={groupSheetOpenSignal}>
561
+ <Loading fallback={<GroupMembersSkeleton />}>
562
+ <GroupMembers />
563
+ </Loading>
564
+ </Dialog>
565
+ ```
566
+
567
+ Without it the nearest boundary is the one at the root of the app, and a
568
+ suspension there empties the whole page — taking with it the popup element the
569
+ browser is in the middle of animating. The alternative is the component drawing
570
+ its own wait (`useAsyncData(action, { loading: true })`), which needs no
571
+ boundary; what is not an option is neither of the two.
572
+
555
573
  What the popup gains is everything a page has:
556
574
 
557
575
  - the request leaves **with the screen**, in parallel with the rest of what the
@@ -11,6 +11,7 @@ then on there are two owners for one truth.
11
11
  - [Why it is not merely shorter](#why-it-is-not-merely-shorter)
12
12
  - [What is left for the callback](#what-is-left-for-the-callback)
13
13
  - [What binds itself](#what-binds-itself)
14
+ - [A control that shows part of a bigger answer](#a-control-that-shows-part-of-a-bigger-answer)
14
15
  - [When there is no binding](#when-there-is-no-binding)
15
16
 
16
17
  ## The three shapes
@@ -119,6 +120,39 @@ Binding the state does not remove the callback — it removes one job from it:
119
120
  | a value proposed to a control | `command="--navi-update"` | [control_value.md](./control_value.md#a-button-that-proposes-a-value-is---navi-update) |
120
121
  | the state of an async run | an `action`, read by `useAsyncData` | [actions.md](./actions.md) |
121
122
 
123
+ ## A control that shows part of a bigger answer
124
+
125
+ "Who writes the score" has three answers — a player of the game, the organizer
126
+ alone, nobody — and only two of them are rows: "nobody" is a switch under the
127
+ list. So the state holds a value the list cannot show, and binding the list to
128
+ that state leaves it showing nothing whenever the switch is on.
129
+
130
+ The reflex is to fall back on `value` + `uiAction` and remember the row by hand.
131
+ The finding is elsewhere: **the list is not asking that question**. It asks
132
+ "which of these two", the switch asks "anybody at all", and the answer the
133
+ screen sends is made of both:
134
+
135
+ ```jsx
136
+ const scorerSignal = signal("player"); // what the list asks: one of ITS rows
137
+ const nobodySignal = signal(false); // what the switch asks
138
+ // what the screen answers, made of the two
139
+ const whoWritesSignal = computed(() =>
140
+ nobodySignal.value ? "nobody" : scorerSignal.value,
141
+ );
142
+
143
+ <List selectable signal={scorerSignal}>…</List>
144
+ <Input type="checkbox" signal={nobodySignal} />
145
+ ```
146
+
147
+ Each control binds the state it is about, and the value that spans them is
148
+ computed from those — never held a second time. It also gives back for free what
149
+ the hand-written version had to arrange: the row chosen before the switch went
150
+ on is still chosen when it goes off, because nothing ever cleared it.
151
+
152
+ The rule underneath: a control shows values, so the state it binds is the one
153
+ whose values it can show. A state holding more than that is more than one
154
+ question.
155
+
122
156
  ## When there is no binding
123
157
 
124
158
  Sometimes the state being wired by hand has no prop for it. That is not a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.146",
3
+ "version": "0.29.147",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {