@jsenv/navi 0.29.74 → 0.29.76

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.
@@ -99,6 +99,17 @@ consistency across the app, not from any single call site.
99
99
  before hand-writing an offset to clear a `FixedBar`, before reaching for
100
100
  `env(safe-area-inset-*)` directly, and before making an app simulate a phone
101
101
  screen.
102
+ - `docs/dialog_shape.md` — where a `Dialog` sits and how big it gets: bounds
103
+ rather than a width, the container ceiling no prop can exceed, the two shapes
104
+ one dialog has (centered box vs. bottom sheet under
105
+ `dockedOnSmallTouchScreen`) and which bounds apply to which — `maxWidth`
106
+ describes the centered shape and docking withdraws it, so both can be stated
107
+ at once. Also `expandX`/`expandY` vs. docking, `marginWithContainer` deciding
108
+ the gap and the ceiling together, `sizing="frozen"`, `layer="local"`, and the
109
+ `dialog*` props a `Picker`/`SplitButton` forwards. Read it before deriving
110
+ `smallTouchScreenSignal` in an app to change a dialog's size, before passing
111
+ `expandX={false}` to stop a dialog sprawling, and before writing CSS to make a
112
+ dialog fit the screen.
102
113
  - `docs/scroll.md` — where scrolling happens: what turns `Box`
103
114
  `header`/`body`/`footer` on, `FixedBar` space, `List`'s `scroller`, scroll
104
115
  inside a `Dialog`/`Popover`, and what a scroll does to hover
@@ -0,0 +1,223 @@
1
+ # The shape of a dialog
2
+
3
+ Where a `Dialog` sits and how big it gets. What happens _inside_ it (scrolling,
4
+ `header`/`body`/`footer`) is [`scroll.md`](./scroll.md); what opens it is
5
+ [`popup_open.md`](./popup_open.md).
6
+
7
+ - [A dialog is sized by its content, never by a `width`](#a-dialog-is-sized-by-its-content-never-by-a-width)
8
+ - [The ceiling nobody sets](#the-ceiling-nobody-sets)
9
+ - [One dialog, two shapes](#one-dialog-two-shapes)
10
+ - [Saying the two shapes at once](#saying-the-two-shapes-at-once)
11
+ - [`expand` is not "docked", and `expandX={false}` is not "do not sprawl"](#expand-is-not-docked-and-expandxfalse-is-not-do-not-sprawl)
12
+ - [`marginWithContainer` decides the gap AND the ceiling](#marginwithcontainer-decides-the-gap-and-the-ceiling)
13
+ - [Holding a size while the dialog is open](#holding-a-size-while-the-dialog-is-open)
14
+ - [A `layer="local"` dialog answers to its container](#a-layerlocal-dialog-answers-to-its-container)
15
+ - [Reaching all of this through a `Picker` or a `SplitButton`](#reaching-all-of-this-through-a-picker-or-a-splitbutton)
16
+
17
+ ## A dialog is sized by its content, never by a `width`
18
+
19
+ There is no `width` prop, and that is the whole design: a dialog is a surface
20
+ laid over the app, and what it holds is what knows how wide it should be. What
21
+ a caller states are **bounds** — a floor under a dialog too narrow for its
22
+ content, a ceiling over one that would sprawl:
23
+
24
+ | what you want to say | how |
25
+ | --------------------------------------- | ------------------------- |
26
+ | "not narrower than this" | `minWidth` / `minHeight` |
27
+ | "not wider than this" | `maxWidth` / `maxHeight` |
28
+ | "as wide as the container allows" | `expandX` / `expandY` |
29
+ | "as wide as the control that opened it" | `sizeFromAnchor` (opt-in) |
30
+
31
+ `sizeFromAnchor` is off by default and that is deliberate: unlike a `Popover`,
32
+ a dialog is not attached to what opened it, so following that element's box is
33
+ a choice, not the norm. It only ever sets a **floor**
34
+ (`--anchor-width`/`--anchor-height`), never a width.
35
+
36
+ ## The ceiling nobody sets
37
+
38
+ Above every bound a caller passes there is one navi always applies: the
39
+ container, minus `marginWithContainer` on both sides
40
+ (`--dialog-maxmax-width` / `--dialog-maxmax-height`). It is not a default that
41
+ a larger `maxWidth` overrides — it wins, always. A `minWidth` too large for the
42
+ screen is clamped by it too, so **no combination of props can produce a dialog
43
+ that overflows its container.** Stop trying to defend against that case.
44
+
45
+ Two consequences worth knowing before writing CSS of your own:
46
+
47
+ - the caps are applied to the dialog's **size**, not merely to its position.
48
+ That is what makes a centered dialog follow the mobile virtual keyboard for
49
+ free: the ceiling is expressed against the app's live screen
50
+ (`--navi-app-width`/`--navi-app-height`, which track the visual viewport), so
51
+ the browser reflows the dialog as the keyboard opens. Nothing to wire.
52
+ - "the container" is the **app's own screen** for `layer="top"` — the visual
53
+ viewport, or the narrower one the app declared with `--navi-app-max-width`
54
+ (see [`safe_area.md`](./safe_area.md)) — and the positioned ancestor for
55
+ `layer="local"`.
56
+
57
+ ## One dialog, two shapes
58
+
59
+ `dockedOnSmallTouchScreen` is the whole small-screen story in one prop: on a
60
+ small touch screen the dialog stops being a centered box and becomes a bottom
61
+ sheet; everywhere else nothing changes.
62
+
63
+ Both halves of the name matter. Touch alone would dock a tablet or a kiosk
64
+ panel — a whole screen away from where the finger just tapped. Size alone would
65
+ dock a narrow desktop window, which is still a mouse. `smallTouchScreenSignal`
66
+ answers both, by shape rather than by a box of maximum dimensions (a phone is a
67
+ narrow slab, in either orientation), and it is **live**: turning the phone
68
+ re-resolves the dialog.
69
+
70
+ What docking supplies — defaults only, so any single axis of the sheet can be
71
+ adjusted without giving up the rest:
72
+
73
+ | supplied | value | why |
74
+ | --------------------- | ---------- | ----------------------------------------------------- |
75
+ | `positionArea` | `"bottom"` | where the thumbs rest and where the keyboard comes up |
76
+ | `marginWithContainer` | `0` | a sheet is flush, or it is not a sheet |
77
+ | `expandX` | `true` | container-wide, same reason |
78
+ | `scrollCapture` | `true` | a drag past the sheet's edge must not reach the page |
79
+
80
+ Plus a swipe-down-to-close, held by the sheet's `header` (a `Box` with the
81
+ `header` prop) and by anything carrying `data-swipe-grip` — never by the whole
82
+ sheet, so a board something is dragged across keeps its own gestures. See
83
+ [`drag_to_travel.md`](./drag_to_travel.md).
84
+
85
+ **`expandY` (or `expand`) cancels docking outright.** A dialog already filling
86
+ the height is on the bottom edge docking would bring it to; all docking could
87
+ still do is take away the shape the caller asked for, and arm a swipe-down on
88
+ something that never rose.
89
+
90
+ ## Saying the two shapes at once
91
+
92
+ The sentence an app almost always wants is two sentences:
93
+
94
+ > Keep this dialog between 12 and 16rem so it does not sprawl on a wide window
95
+ > and does not collapse to its shortest line. **And when it is a bottom sheet,
96
+ > forget all that: a sheet is flush and full width.**
97
+
98
+ Both halves are written together, and each applies where it means something:
99
+
100
+ ```jsx
101
+ <Dialog dockedOnSmallTouchScreen minWidth="12rem" maxWidth="16rem">
102
+ ```
103
+
104
+ | prop | centered box | docked sheet |
105
+ | ----------- | ------------ | ----------------------------------------------------- |
106
+ | `maxWidth` | applies | **withdrawn** — the sheet is container-wide |
107
+ | `minWidth` | applies | stops mattering (the floor is below full width) |
108
+ | `maxHeight` | applies | applies — a sheet is content-tall, not container-tall |
109
+ | `minHeight` | applies | applies |
110
+
111
+ `maxWidth` is an answer about the _centered_ shape: "do not sprawl on a wide
112
+ window". A sheet spanning its container's full width, flush against the two
113
+ side edges, **is** the docked mode — so docking withdraws that ceiling rather
114
+ than capping the sheet with it. The container ceiling still holds, as always.
115
+
116
+ > **Trap: do not re-derive the docking condition in the app.** This looks like
117
+ > the way to say it and is subtly wrong:
118
+ >
119
+ > ```jsx
120
+ > // WRONG
121
+ > maxWidth={smallTouchScreenSignal.value ? undefined : "16rem"}
122
+ > ```
123
+ >
124
+ > Docking is not `smallTouchScreenSignal` — it is
125
+ > `dockedOnSmallTouchScreen && smallTouchScreenSignal.value && !expandY`. A
126
+ > dialog that also sets `expandY` never docks, so the cap must never be
127
+ > withdrawn there, and the line above withdraws it anyway. Beyond being wrong,
128
+ > it duplicates a condition navi owns at every call site (it drifts the day
129
+ > "docked" gains a rule), and it reads as a bug: it says nothing about bottom
130
+ > sheets to the next person. State both bounds plainly and let the dialog
131
+ > resolve its own shape.
132
+
133
+ ## `expand` is not "docked", and `expandX={false}` is not "do not sprawl"
134
+
135
+ `expandX`/`expandY` mean "grow to the ceiling" — the container ceiling above,
136
+ capped in turn by `maxWidth`/`maxHeight` when they apply. `expand` is the
137
+ shorthand for both.
138
+
139
+ Since docking _supplies_ `expandX`, passing it explicitly takes the caller out
140
+ of that default:
141
+
142
+ ```jsx
143
+ // The sheet stops being flush: a floating box at the bottom of the screen.
144
+ <Dialog dockedOnSmallTouchScreen expandX={false} />
145
+ ```
146
+
147
+ That is consistent — an explicitly passed prop wins over a docked default — and
148
+ it is still the trap, because the two props read as answering different
149
+ questions (one about the centered shape, one about the phone) when they answer
150
+ the same one. If what you meant was "cap the centered box", that is `maxWidth`,
151
+ and docking withdraws it on its own.
152
+
153
+ ## `marginWithContainer` decides the gap AND the ceiling
154
+
155
+ One prop, because they are one fact: the gap a dialog keeps with the edges of
156
+ its container is also what its size ceiling is computed from. Writing them
157
+ separately is how a dialog ends up flush on one side and inset on the other.
158
+
159
+ It defaults to a share of whatever holds the dialog (`3appw` for `layer="top"`,
160
+ `3cqw` for `layer="local"`) and accepts a spacing token (`"s"`, `"m"`…), a
161
+ number of pixels, or a viewport length — `appw`/`apph` being the app's own
162
+ screen, `vvw`/`vvh` the visual viewport, which shrinks when the keyboard opens.
163
+ Pass `0` for something meant to sit flush (a side panel), and note that docking
164
+ already passes `0` for you.
165
+
166
+ ## Holding a size while the dialog is open
167
+
168
+ `sizing="frozen"` measures the dialog once and holds it until it closes; what
169
+ no longer fits — or no longer fills it — becomes the scroll's business. It is
170
+ for a surface acted upon while it is open: marking a notification read,
171
+ emptying a queue, swapping between two slides of different heights. The row
172
+ being aimed at must not move under the finger.
173
+
174
+ Two things make it safe to reach for:
175
+
176
+ - the freeze writes a `width`/`height`, never a `min-*` — the caps above keep
177
+ winning, so a frozen dialog still fits when the phone is turned;
178
+ - the measure is taken at the first render where the prop says `"frozen"`, so a
179
+ dialog opening on skeletons says `sizing={loading ? "auto" : "frozen"}` and
180
+ is measured once the real content has arrived. Closing releases it.
181
+
182
+ ## A `layer="local"` dialog answers to its container
183
+
184
+ `layer="top"` (the default) is a real `<dialog>` in the browser's top layer:
185
+ native focus trap, `Escape`, hardware back-button dismissal, the rest of the
186
+ document made inert. `layer="local"` stays in normal document flow, confined to
187
+ and clipped by its own positioned ancestor.
188
+
189
+ For the shape, that swaps what every bound is measured against: the container
190
+ becomes that ancestor's box, read through
191
+ `--container-position-remaining-width`/`-height`, and the default margin is
192
+ read in container units. Its own container's scroll is always locked while it
193
+ is open — its backdrop covers the scrollport, not the scrolled content, so
194
+ scrolling would slide the dialog away and reveal what the backdrop does not
195
+ cover. `scrollCapture` is what extends that lock to the whole page.
196
+
197
+ One accepted limitation, not an oversight: a local dialog **cannot** be
198
+ dismissed by the hardware/gesture back button. No web API hooks into that
199
+ outside the browser's own modal-dismissal stack, which only a genuine
200
+ `showModal()` element joins.
201
+
202
+ ## Reaching all of this through a `Picker` or a `SplitButton`
203
+
204
+ A picker's popup is a popover or a dialog depending on the screen, so it
205
+ exposes both sets of bounds under prefixed names, and they mean exactly what
206
+ they mean on `Dialog` itself:
207
+
208
+ `dialogMinWidth`, `dialogMinHeight`, `dialogMaxWidth`, `dialogMaxHeight`,
209
+ `dialogExpand`, `dialogExpandX`, `dialogExpandY`, `dockedOnSmallTouchScreen`,
210
+ `marginWithContainer` — plus `popoverMaxHeight` and `popupWidthFitContent` for
211
+ the other shape.
212
+
213
+ `SplitButton` forwards the same set to the picker it wraps. Anything not in
214
+ that set lands on the split button's own box instead — so a prop that seems to
215
+ do nothing to the menu is worth checking against that list first (see
216
+ `POPUP_PROP_SET` in `src/control/input/split_button.jsx`).
217
+
218
+ ---
219
+
220
+ Reference: `src/layout/dialog.jsx` (the stylesheet at the top of the file holds
221
+ the cap arithmetic), `src/layout/responsive.js` (`smallTouchScreenSignal`),
222
+ `src/control/picker/picker_custom.jsx` (how `--picker-dialog-*` bridges to
223
+ `--dialog-*`), and `src/layout/demos/1_dialog_demo.html`.
package/docs/z_index.md CHANGED
@@ -74,14 +74,14 @@ file is the overview, this table is its summary. Bands are a decade apart so
74
74
  one can grow without reaching the next, and so a value seen in devtools says
75
75
  which band it came from.
76
76
 
77
- | Band | Token | Value |
78
- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------- |
79
- | Top layer (`Dialog`/`Popover` with `layer="top"`) | — | above everything |
80
- | `Dialog`/`Popover` with `layer="local"`, their backdrop, callouts | `--navi-z-index-popup`, `--navi-z-index-callout` | 1000 `+ stack order` |
81
- | `FixedBar` | `--navi-z-index-bar` | 100 |
82
- | Sticky while something scrolls under: `List` header/footer/group labels, `SidePanel` head/foot, `Box` header/footer | `--navi-z-index-sticky` | 10 |
83
- | A `Group` member under the pointer, then the one holding focus | `--navi-z-index-control-hovered`, `--navi-z-index-control-focused` | 1, 2 |
84
- | `Table` sticky cells, drag, resize | `src/control/table/z_indexes.js` | 1–7, derived from each other |
77
+ | Band | Token | Value |
78
+ | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------- |
79
+ | Top layer (`Dialog`/`Popover` with `layer="top"`) | — | above everything |
80
+ | `Dialog`/`Popover` with `layer="local"`, their backdrop, callouts | `--navi-z-index-popup`, `--navi-z-index-callout` | 1000 `+ stack order` |
81
+ | `FixedBar` | `--navi-z-index-bar` | 100 |
82
+ | Sticky while something scrolls under: `List` header/footer/group labels, `SidePanel` head/foot, `Box` header/footer, `<Box sticky>` | `--navi-z-index-sticky` | 10 |
83
+ | A `Group` member under the pointer, then the one holding focus | `--navi-z-index-control-hovered`, `--navi-z-index-control-focused` | 1, 2 |
84
+ | `Table` sticky cells, drag, resize | `src/control/table/z_indexes.js` | 1–7, derived from each other |
85
85
 
86
86
  What to read from it:
87
87
 
@@ -138,7 +138,7 @@ the label behind the rows, it puts it behind that background and out of sight.
138
138
  See the "Sticky parts" chapter of
139
139
  [12_list_demo.html](../src/control/demos/12_list_demo.html).
140
140
 
141
- `Box`'s own `header`/`footer` take the opposite default, and for a reason worth
141
+ `<Box sticky>` and `Box`'s own `header`/`footer` take the opposite default, and for a reason worth
142
142
  knowing: they are in the band **always**, not only while stuck. `List` can tell
143
143
  — it measures its parts against its own scroller. A `Box` cannot: it is the
144
144
  generic scrolling area, its content is whatever the app puts in it, and a
@@ -151,6 +151,32 @@ one call site that knows nothing inside is positioned.
151
151
  shows the band, what `auto` would look like, and what the band costs, side by
152
152
  side.
153
153
 
154
+ `<Box sticky>` — the one an app writes by hand — is that same generic case, so
155
+ it carries the band too, from the `sticky` prop itself (`position="sticky"`
156
+ included):
157
+
158
+ ```jsx
159
+ // position: sticky + z-index: var(--navi-z-index-sticky). Nothing to write.
160
+ <Box sticky bottom>
161
+ <SubmitButton />
162
+ </Box>
163
+ ```
164
+
165
+ Without it the box is a positioned element at `z-index: auto`, and anything the
166
+ page raised passes in front of it — a `Group` member holding focus is at 2, so
167
+ it is seen crossing a submit bar the box was written to keep last. DOM order
168
+ cannot answer that (2 beats `auto` whatever the order) and `isolation: isolate`
169
+ neither (the common parent holds both, isolating it does not reorder them), so
170
+ before this the app had no move left but a literal of its own.
171
+
172
+ An explicit `zIndex` wins, `zIndex="auto"` included — the way
173
+ `--box-header-z-index` writes the band back at a call site that knows better:
174
+
175
+ ```jsx
176
+ // Back to auto: this one is meant to slide under the card that follows it.
177
+ <Box sticky top zIndex="auto" />
178
+ ```
179
+
154
180
  ### Why a `Group` member is not isolated
155
181
 
156
182
  `Group` overlaps its members by one border width, so the one the user is on has
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.74",
3
+ "version": "0.29.76",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {
@@ -29,7 +29,7 @@
29
29
  "prepublishOnly": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@jsenv/dom": "0.17.23",
32
+ "@jsenv/dom": "0.17.25",
33
33
  "@jsenv/humanize": "1.7.8",
34
34
  "@jsenv/validity": "0.4.2"
35
35
  },