@waveso/docs 0.11.0 → 0.12.0
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/CHANGELOG.md +275 -0
- package/README.md +3 -3
- package/dist/react/nav.js +14 -11
- package/dist/react/search-dialog.js +87 -15
- package/dist/styles.css +460 -42
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,280 @@
|
|
|
1
1
|
# @waveso/docs
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3551951: **The search results and the sidebar say "there is more this way" the same way
|
|
8
|
+
the table does, and hide their scrollbars where they can.**
|
|
9
|
+
|
|
10
|
+
Four gradients on the search list, exactly as `.wave-docs-table-scroll` does it:
|
|
11
|
+
the two `local` covers are painted in the surface colour and travel _with_ the
|
|
12
|
+
rows, so each sits over its shadow only while that edge is at rest, and the two
|
|
13
|
+
`scroll` shadows are pinned to the box. A grey edge appears on precisely the side
|
|
14
|
+
that has rows off-screen, with no listener, no state and no hydration.
|
|
15
|
+
|
|
16
|
+
⚠️ IT REPLACED A MASK, WHICH WAS THE WRONG TOOL TWICE OVER. A mask fades content
|
|
17
|
+
to _transparent_, so what showed through was the dialog's own white — a hole
|
|
18
|
+
rather than a shadow. And it cannot be conditional: CSS has no way to ask whether
|
|
19
|
+
there is anything above to scroll to, so the first and last rows were softened
|
|
20
|
+
even at rest.
|
|
21
|
+
|
|
22
|
+
## The sidebar needs its shadow above the content, not behind it
|
|
23
|
+
|
|
24
|
+
⚠️ `.wave-docs-sidebar` PAINTS ITS OWN GROUND, AND MUST. It is in the theme's
|
|
25
|
+
opt-in rule — the one that installs a ground wherever it installs a foreground
|
|
26
|
+
ramp — so a consumer mounting the tree alone gets one, and `styles.browser.test`
|
|
27
|
+
requires the shell to be a single surface. An element's background is painted
|
|
28
|
+
_below_ its children, so the four-gradient trick was covered by a child 480px
|
|
29
|
+
tall: measured 255/255 at the top edge with the nav scrolled — declared,
|
|
30
|
+
computed, and invisible.
|
|
31
|
+
|
|
32
|
+
So the navigation's overflow moved to an inner box, `.wave-docs-layout__sidebar-scroll`,
|
|
33
|
+
and the panel keeps only its edges.
|
|
34
|
+
|
|
35
|
+
⚠️ THAT SPLIT IS THE FIX, AND EVERY VERSION BEFORE IT FOUGHT THE SCROLLER. An
|
|
36
|
+
absolutely positioned child of a scroll container is laid out against that
|
|
37
|
+
container's padding box and _joins its scrollable overflow_ — so nothing placed
|
|
38
|
+
inside a scroller is ever pinned to it. `position: sticky` only clamps, and a
|
|
39
|
+
clamp is still a thing that travels until it catches. With the scrolling on an
|
|
40
|
+
inner element the panel is an ordinary positioned box: `top: 0` and `bottom: 0`
|
|
41
|
+
are its own edges, and the bands have no scroll to have a position within.
|
|
42
|
+
|
|
43
|
+
The timeline is declared on the scroller and `timeline-scope`d up to the panel,
|
|
44
|
+
because a `scroll-timeline` name is visible to the declaring element's
|
|
45
|
+
descendants and these pseudo-elements belong to its parent.
|
|
46
|
+
|
|
47
|
+
⚠️ AND THE SHADOW BELONGS TO THE PANEL RATHER THAN THE TREE, because
|
|
48
|
+
`.wave-docs-sidebar` paints an opaque ground — it is in the theme's opt-in rule,
|
|
49
|
+
and the shell has to be one surface — so a background on the scroller would be
|
|
50
|
+
painted underneath it. Measured 255/255 when it was.
|
|
51
|
+
|
|
52
|
+
⚠️ AND THE NAV'S FADE IS A LENGTH, NOT A PERCENTAGE OF THE SCROLL. The table's
|
|
53
|
+
keyframes shape their fade in percentages, which are percentages of the
|
|
54
|
+
container's _total_ scroll range — fine on a table, wrong on a navigation. A nav
|
|
55
|
+
with 1000px of scroll turns the same `2%…8%` into 20px…80px, so the shadow spends
|
|
56
|
+
eighty pixels of scrolling arriving at full strength, which reads exactly like it
|
|
57
|
+
is moving with the content rather than pinned to the edge. `animation-range: 0
|
|
58
|
+
1rem` makes it the same short distance whatever the nav's height, identical on a
|
|
59
|
+
six-page site and a three-hundred-page one, with plain fades for keyframes
|
|
60
|
+
because the shaping now lives in the range.
|
|
61
|
+
|
|
62
|
+
⚠️ THE SCROLLBAR IS HIDDEN ONLY WHERE THE SHADOW EXISTS. Both live in the same
|
|
63
|
+
`@supports (animation-timeline: scroll())`, so Firefox — where scroll-driven
|
|
64
|
+
animations have not shipped — keeps the thin bar rather than losing the bar and
|
|
65
|
+
the shadow together. A scrollbar is the one cue that a column has more below it,
|
|
66
|
+
and taking it away before something replaces it trades a slab for nothing.
|
|
67
|
+
|
|
68
|
+
⚠️ AND A SECOND TEST LEARNED THAT `getAnimations()` IS NOT "TRANSITIONS". A
|
|
69
|
+
scroll-driven animation's `finished` promise resolves when the _scroll position_
|
|
70
|
+
reaches the end, which is to say never on a panel sitting at the top — so
|
|
71
|
+
awaiting the whole list hung `sidebar.browser.test.tsx` for its full 15s timeout
|
|
72
|
+
the moment this shadow existed. `nav.browser.test.tsx` had already been bitten by
|
|
73
|
+
the table's; this is the second place the assumption lived.
|
|
74
|
+
|
|
75
|
+
- 70649b8: **Search results get a glyph, a breadcrumb, and no scrollbar.**
|
|
76
|
+
|
|
77
|
+
**A page or a section, as an icon.** Which one is read off the `href` rather
|
|
78
|
+
than a new index field — a record with an anchor is a section within a page and
|
|
79
|
+
one without is the page itself, which is what `buildSearchIndex` means by its
|
|
80
|
+
lead record — so the records did not grow by a byte.
|
|
81
|
+
|
|
82
|
+
⚠️ AND THE ICON IS NOT DECORATION, IT IS THE ALIGNMENT. With no icon a result's
|
|
83
|
+
heading sat on the same column as the input's _magnifier_ while the input's own
|
|
84
|
+
text sat 24px right of it: three left edges where a reader expects two, which is
|
|
85
|
+
what made the dialog read as stacked levels rather than as a list under a field.
|
|
86
|
+
The icon takes the magnifier's column and the text takes the input's, so the two
|
|
87
|
+
rows are the same shape. Measured: 18px and 42px, both rows, plus the footer's
|
|
88
|
+
key caps on the 18.
|
|
89
|
+
|
|
90
|
+
**The second line is a breadcrumb.** The segments were always a trail from the
|
|
91
|
+
site's root to the page; slashes made them read as a URL to parse and `›` makes
|
|
92
|
+
them read as what they are. ⚠️ SAFE ONLY BECAUSE THAT LINE IS `aria-hidden` —
|
|
93
|
+
more than one screen reader pronounces the character, which is exactly why
|
|
94
|
+
`spokenName` joins with commas instead. The two lines carry the same fact in the
|
|
95
|
+
form each audience can use.
|
|
96
|
+
|
|
97
|
+
⚠️ THE SEPARATOR IS THE PACKAGE'S OWN CHEVRON, NOT A CHARACTER, AND TWO
|
|
98
|
+
CHARACTERS WERE MEASURED AND REJECTED FIRST. `›` (U+203A) inks 4.93px tall in
|
|
99
|
+
the shipped mono face at 11px, against 6.32 for an `s` and 8.51 for a `b` — it
|
|
100
|
+
sat visibly below the words it separated. `❯` (U+276F) inks 8.03 and fixed the
|
|
101
|
+
height _on one machine_: `ui-monospace` resolves to SF Mono here, Consolas on
|
|
102
|
+
Windows, Liberation Mono on Linux, and a glyph missing from one of those falls
|
|
103
|
+
back to another face at another width. U+3009 already demonstrated exactly that,
|
|
104
|
+
measuring 11.24px against the 6.62 cell.
|
|
105
|
+
|
|
106
|
+
An SVG has no font to be missing from — and it is the same Lucide chevron the
|
|
107
|
+
sidebar and the pager draw, which is the real argument: everything else here is
|
|
108
|
+
that one set.
|
|
109
|
+
|
|
110
|
+
⚠️ AND THE TRAIL IS SET IN THE SANS, NOT IN THE MONO IT STARTED IN — WHICH IS
|
|
111
|
+
WHAT MADE THE SEPARATOR RELIABLE. A monospace face advances every glyph one
|
|
112
|
+
cell, so the segments marched on a rigid grid an icon could not join, and its
|
|
113
|
+
metrics are whatever the machine resolves: SF Mono here, Consolas on Windows,
|
|
114
|
+
Liberation Mono on Linux. The cap height an inline glyph is sized against
|
|
115
|
+
therefore moved from reader to reader, which is the same portability problem
|
|
116
|
+
that ruled out `›` and `❯` as characters — arriving a second time through the
|
|
117
|
+
face rather than through the glyph. Proportional sans has ordinary spacing and
|
|
118
|
+
one set of metrics to match. `font-size` goes up a notch with it, because a
|
|
119
|
+
mono face reads larger at the same size and holding the number would have
|
|
120
|
+
shrunk the line.
|
|
121
|
+
|
|
122
|
+
⚠️ AND THE `viewBox` IS CROPPED TO THE STROKE, WHICH IS WHAT RETIRED FOUR
|
|
123
|
+
CORRECTIONS. On Lucide's full 24 grid the chevron occupies x 9–15 and y 6–18,
|
|
124
|
+
so a square box around it was five sixths air across and half air down — and
|
|
125
|
+
every one of those gaps had to be subtracted back by hand: a `vertical-align`,
|
|
126
|
+
a negative `margin-inline`, a negative `margin-block`, and a size chosen to
|
|
127
|
+
make the surplus come out right. `8 5 8 14` is the painted extent, caps and
|
|
128
|
+
joins included. The box is the glyph, so it sits on the baseline the way a
|
|
129
|
+
letter does with nothing declared, and the air beside it is one positive
|
|
130
|
+
`margin-inline`.
|
|
131
|
+
|
|
132
|
+
⚠️ AND IT CARRIES `overflow: visible`, WITHOUT WHICH THE POINT IS FLAT. An
|
|
133
|
+
`svg` clips to its viewport by default and this `viewBox` is exactly the
|
|
134
|
+
stroke's extent, so the round join at the tip lands on the box's own edge and
|
|
135
|
+
loses its outermost anti-aliased pixel. Nothing overlaps anything: what spills
|
|
136
|
+
is a fraction of a pixel.
|
|
137
|
+
|
|
138
|
+
## The scrollbar is hidden, and it cannot be conditional
|
|
139
|
+
|
|
140
|
+
⚠️ THERE IS NO CSS WAY TO SHOW A SCROLLBAR ONLY WHILE SCROLLING. That behaviour
|
|
141
|
+
is the platform's: macOS draws overlay scrollbars that fade in on scroll and out
|
|
142
|
+
after it, and this list gets it for free there. Windows and Linux draw a classic
|
|
143
|
+
one that is always present, and the only ways to make it come and go are a
|
|
144
|
+
JavaScript timer toggling a class or a scrollbar drawn from scratch in script —
|
|
145
|
+
which is what a `ScrollArea` component is, and neither belongs in a package whose
|
|
146
|
+
whole argument is what it does _not_ ship to a reader.
|
|
147
|
+
|
|
148
|
+
So it is hidden, as `@waveso/app` does with `.scrollbar-none`. What replaces it
|
|
149
|
+
is the keyboard rather than a fade: the footer says `↑ ↓ Select`, the list is
|
|
150
|
+
driven by `aria-activedescendant`, and arrowing past the last visible row scrolls
|
|
151
|
+
it and loads the next page.
|
|
152
|
+
|
|
153
|
+
⚠️ AND `pageSize` STAYS A WINDOW, NOT A CAP. It was a hard ceiling of 8 once, and
|
|
154
|
+
that was removed with measurements: on a six-page site "docs" matches 18, so
|
|
155
|
+
results were unreachable and the live region announced "8 results" — not a
|
|
156
|
+
smaller truth but a false one. Rendering every row of a 300-page corpus costs
|
|
157
|
+
40ms, 128ms at 4x throttle, which is what paging exists to avoid; the ceiling
|
|
158
|
+
was never what made it fast.
|
|
159
|
+
|
|
160
|
+
The size budget moves with the glyphs, and the figures the README and the
|
|
161
|
+
installation page publish move with it — a document here may not understate what
|
|
162
|
+
this package costs.
|
|
163
|
+
|
|
164
|
+
- 78053a6: **Nothing in the search dialog is selected until the reader selects it.**
|
|
165
|
+
|
|
166
|
+
Typing lit the first result the instant it arrived, so a row changed under a
|
|
167
|
+
reader for something they had not chosen — and on this package's accent that
|
|
168
|
+
reads as a decision already made. `activeIndex` starts at `-1` now: an arrow key
|
|
169
|
+
or the pointer moving over a row is what selects.
|
|
170
|
+
|
|
171
|
+
⚠️ AND EVERY GUARD FOR IT ALREADY EXISTED, WHICH IS WHY THIS IS ONE NUMBER.
|
|
172
|
+
`hits[-1]` is `undefined`, so no `aria-activedescendant` is written and Enter
|
|
173
|
+
returns without opening anything. The one place that needed teaching is the
|
|
174
|
+
arrow keys: they wrap modulo the list, and `(-1 - 1 + n) % n` is the _second to
|
|
175
|
+
last_, so Up from a fresh query landed one short of the end.
|
|
176
|
+
|
|
177
|
+
**The active row is the table header's grey**, easing on the same 150ms the
|
|
178
|
+
sidebar's rows use. Moving the pointer over a result activates it, so hovering
|
|
179
|
+
_is_ this state; there is no second rule for it.
|
|
180
|
+
|
|
181
|
+
It has been four things: a 2px accent ring, which read as a component borrowed
|
|
182
|
+
from somewhere else and came and went as a reader arrowed; the trigger's border
|
|
183
|
+
pair, which meant bordering _every_ row to make one edge legible and turned a
|
|
184
|
+
list into a stack of cards; an accent tint, which was the same colour family as
|
|
185
|
+
the field above it. A row is a list item and its state is a colour — the field is
|
|
186
|
+
a control, and that is what wears the trigger's border.
|
|
187
|
+
|
|
188
|
+
⚠️ `bg-subtle` ON `bg` IS ABOUT 1.02:1, SO THE TINT IS A HINT AND NOT AN
|
|
189
|
+
INDICATOR. The marker going `fg-subtle` to `fg` is the part that carries the
|
|
190
|
+
state — text contrast rather than non-text, and the sidebar's own hover. What
|
|
191
|
+
must not happen is the tint being left to carry it alone.
|
|
192
|
+
|
|
193
|
+
## The field stops drawing a ring it could never put down
|
|
194
|
+
|
|
195
|
+
⚠️ A TEXT INPUT MATCHES `:focus-visible` WHENEVER IT IS FOCUSED, HOWEVER FOCUS
|
|
196
|
+
ARRIVED — that is the spec, not a heuristic — and this dialog focuses its input
|
|
197
|
+
the moment it opens. So the accent ring was not a state, it was the field's
|
|
198
|
+
permanent appearance, and it stopped the field looking like the bordered grey
|
|
199
|
+
control the reader clicked to get there.
|
|
200
|
+
|
|
201
|
+
The edge darkens to `--wave-docs-border-strong` instead, which is what the
|
|
202
|
+
trigger does under the pointer, so the field is the trigger in both of its
|
|
203
|
+
states.
|
|
204
|
+
|
|
205
|
+
⚠️ AND THE INDICATOR IS NOT LOST WITH IT, BECAUSE A TEXT FIELD HAS ONE OF ITS
|
|
206
|
+
OWN. The caret is the platform's focus indication for a text box, it is in this
|
|
207
|
+
field the whole time the dialog is open, and it is what a reader looks for to see
|
|
208
|
+
where typing goes. That is a different argument from the one this rule used to
|
|
209
|
+
reject: "the dialog frame is the indicator" was a _static_ border that looked
|
|
210
|
+
identical focused and unfocused, and indicated nothing. This edge changes.
|
|
211
|
+
|
|
212
|
+
## The list fades at both edges
|
|
213
|
+
|
|
214
|
+
A mask on the scrollport, so the row being cut off is the one that softens. It
|
|
215
|
+
does not travel with the content: a mask paints against the element's own box, so
|
|
216
|
+
the two stops stay at the top and bottom of the _port_ while the rows move under
|
|
217
|
+
them.
|
|
218
|
+
|
|
219
|
+
⚠️ IT IS UNCONDITIONAL, WHICH IS THE ONE THING TO KNOW. CSS cannot ask "is there
|
|
220
|
+
anything above this to scroll to" — the table's shadow answers that with a
|
|
221
|
+
scroll-driven animation whose timeline goes inactive when nothing overflows, and
|
|
222
|
+
a mask has no equivalent. So the first and last rows carry a little of it at
|
|
223
|
+
rest. At `1.25rem` against a row nearer three times that, it costs the top pixel
|
|
224
|
+
or two of a heading and buys never guillotining one.
|
|
225
|
+
|
|
226
|
+
## The field, and the gap under it
|
|
227
|
+
|
|
228
|
+
**The field is the trigger, expanded.** Same border, same fill, same radius. It
|
|
229
|
+
was a border with no fill — two frames a few pixels apart — and briefly a fill
|
|
230
|
+
with no border, which is a tinted band rather than a control. The trigger has
|
|
231
|
+
always been both, and both together are what reads as a field.
|
|
232
|
+
|
|
233
|
+
⚠️ THE PADDING IS NOT COPIED WITH THEM. The trigger pays `calc(0.5rem - 1px)`
|
|
234
|
+
because it is a compact control in a sidebar; the field pays
|
|
235
|
+
`calc(0.75rem - 1px)` because its glyph has to land on the column the results
|
|
236
|
+
and the footer sit on. The `- 1px` is the border either way — content inside a
|
|
237
|
+
bordered box starts a border further in.
|
|
238
|
+
|
|
239
|
+
The field takes the base radius and the result rows take `-sm`, which is the tier
|
|
240
|
+
system doing what it says: both are controls, and a result row is a list item.
|
|
241
|
+
|
|
242
|
+
⚠️ AND THE GAP UNDER THE FIELD WAS PAID TWICE. The field's margin and the result
|
|
243
|
+
list's top padding both contributed, so the space between the input and the first
|
|
244
|
+
result was double the space above the input — a doubled gap in the one place a
|
|
245
|
+
reader's eye travels on every keystroke. The list drops its top padding; the
|
|
246
|
+
field keeps its margin, because an empty result list is `display: none` and a gap
|
|
247
|
+
paid from there would vanish on the query that matches nothing.
|
|
248
|
+
|
|
249
|
+
### Patch Changes
|
|
250
|
+
|
|
251
|
+
- 688a84c: **The search dialog lines up on one column, and its input is a field rather
|
|
252
|
+
than a band.**
|
|
253
|
+
|
|
254
|
+
The input row was flush to the dialog's frame, so it could only round the two
|
|
255
|
+
corners it shared with it and needed a rule underneath to separate it from the
|
|
256
|
+
results. Inset by the same margin the results list uses, it is a box like they
|
|
257
|
+
are — all four corners rounded, and the gap does the separating.
|
|
258
|
+
|
|
259
|
+
⚠️ AND ITS RADIUS IS THE DIALOG'S MINUS THE GAP, NOT THE DIALOG'S. A rounded box
|
|
260
|
+
inset inside a rounded box is concentric only at `outer - gap`; equal radii run
|
|
261
|
+
the two corners at different curvatures six pixels apart and the field reads as
|
|
262
|
+
pasted onto the dialog rather than set into it.
|
|
263
|
+
|
|
264
|
+
**One column, measured from the bottom up.** The footer's key caps are the
|
|
265
|
+
anchor — the one row whose left edge is a drawn object — and the results' text
|
|
266
|
+
and the input's magnifier are measured to it. All three now start 18px from the
|
|
267
|
+
dialog's inner edge.
|
|
268
|
+
|
|
269
|
+
⚠️ BOXES, NOT INK. A cap's arrow sits its own border and `0.4em` of padding
|
|
270
|
+
inside the cap, so aligning the _glyphs_ would put every other row on a column
|
|
271
|
+
that moves whenever the footer's font size does.
|
|
272
|
+
|
|
273
|
+
⚠️ AND THE INPUT ROW PAYS `calc(0.75rem - 1px)` BECAUSE IT IS THE ONE ROW WITH A
|
|
274
|
+
BORDER. Content inside a bordered box starts a border further in than content
|
|
275
|
+
inside an unbordered one, so equal padding misses by exactly that — the same
|
|
276
|
+
subtraction `--wave-docs-panel-inset` exists for.
|
|
277
|
+
|
|
3
278
|
## 0.11.0
|
|
4
279
|
|
|
5
280
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -69,8 +69,8 @@ Every figure below is a **ceiling**, and `pnpm size` fails the build if the meas
|
|
|
69
69
|
|
|
70
70
|
| | At most |
|
|
71
71
|
| --- | --- |
|
|
72
|
-
| Everything the quick start ships, gzipped | 14.
|
|
73
|
-
| Search dialog and router wiring | 9.
|
|
72
|
+
| Everything the quick start ships, gzipped | 14.9 KB |
|
|
73
|
+
| Search dialog and router wiring | 9.8 KB |
|
|
74
74
|
| Navigation: one sidebar, open and closed | 3.1 KB |
|
|
75
75
|
| Table of contents | 1 KB |
|
|
76
76
|
| Copy-button runtime | 1.1 KB |
|
|
@@ -78,7 +78,7 @@ Every figure below is a **ceiling**, and `pnpm size` fails the build if the meas
|
|
|
78
78
|
| hast over the wire vs HTML, code and tables | 1.12× |
|
|
79
79
|
| Highlighting vs no highlighting | 2.00× |
|
|
80
80
|
|
|
81
|
-
The first row is the honest total: a reader who lands on a page of your documentation downloads under 14.
|
|
81
|
+
The first row is the honest total: a reader who lands on a page of your documentation downloads under 14.9 KB gzipped of JavaScript from this package, and that is the whole of it. No markdown parser and no syntax highlighter reach the browser at all — those run in Node at build time. Drop the search dialog and it is under 4 KB.
|
|
82
82
|
|
|
83
83
|
The one real cost is the middle pair: shipping a tree instead of a string is about 20% more brotli on a prose page, and about 12% on a page with code and tables, where Shiki's token spans dominate both representations equally. That is the price of never handing markup to `dangerouslySetInnerHTML`, and it is the first number a skeptical reviewer should ask for.
|
|
84
84
|
|
package/dist/react/nav.js
CHANGED
|
@@ -86,21 +86,24 @@ function DocsNav({ nav, pathname, Link, label = "Documentation", children, close
|
|
|
86
86
|
className: "wave-docs-layout__sidebar",
|
|
87
87
|
...state === void 0 ? {} : { "data-state": state ? "open" : "closed" },
|
|
88
88
|
...ready ? { "data-ready": "" } : {},
|
|
89
|
-
children: [/* @__PURE__ */
|
|
89
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
90
90
|
ref: navRef,
|
|
91
91
|
id: DOCS_NAV_ID,
|
|
92
92
|
className: "wave-docs-layout__sidebar-nav",
|
|
93
93
|
tabIndex: -1,
|
|
94
|
-
children:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
94
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
95
|
+
className: "wave-docs-layout__sidebar-scroll",
|
|
96
|
+
children: [children, /* @__PURE__ */ jsx(DocsSidebar, {
|
|
97
|
+
nav,
|
|
98
|
+
pathname,
|
|
99
|
+
label,
|
|
100
|
+
Link,
|
|
101
|
+
...expandGroup === void 0 ? {} : { expandGroup },
|
|
102
|
+
...collapseGroup === void 0 ? {} : { collapseGroup },
|
|
103
|
+
...externalLink === void 0 ? {} : { externalLink },
|
|
104
|
+
...icons === void 0 ? {} : { icons }
|
|
105
|
+
})]
|
|
106
|
+
})
|
|
104
107
|
}), /* @__PURE__ */ jsx("button", {
|
|
105
108
|
type: "button",
|
|
106
109
|
className: "wave-docs-layout__sidebar-trigger",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { docsError } from "../docs-error.js";
|
|
3
3
|
import { mergeSearchOptions } from "../search-options.js";
|
|
4
|
-
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { Fragment, useCallback, useEffect, useId, useRef, useState } from "react";
|
|
5
5
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
6
6
|
import { createPortal } from "react-dom";
|
|
7
7
|
//#region src/react/search-dialog.tsx
|
|
@@ -51,7 +51,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
51
51
|
* not itself cause a render.
|
|
52
52
|
*/
|
|
53
53
|
const movedByKeyboard = useRef(false);
|
|
54
|
-
const [activeIndex, setActiveIndex] = useState(
|
|
54
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
55
55
|
const [status, setStatus] = useState("idle");
|
|
56
56
|
/**
|
|
57
57
|
* The trigger's shortcut, split rather than held as one string.
|
|
@@ -166,13 +166,13 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
166
166
|
if (isOpen) return;
|
|
167
167
|
setQuery("");
|
|
168
168
|
setHits([]);
|
|
169
|
-
setActiveIndex(
|
|
169
|
+
setActiveIndex(-1);
|
|
170
170
|
}, [isOpen]);
|
|
171
171
|
useEffect(() => {
|
|
172
172
|
const trimmed = query.trim();
|
|
173
173
|
if (trimmed.length < minQueryLength) {
|
|
174
174
|
setHits([]);
|
|
175
|
-
setActiveIndex(
|
|
175
|
+
setActiveIndex(-1);
|
|
176
176
|
setVisibleCount(pageSize);
|
|
177
177
|
return;
|
|
178
178
|
}
|
|
@@ -182,7 +182,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
182
182
|
if (isCancelled) return;
|
|
183
183
|
setStatus("ready");
|
|
184
184
|
setHits(index.search(trimmed).map(toSearchHit).filter(isSearchHit));
|
|
185
|
-
setActiveIndex(
|
|
185
|
+
setActiveIndex(-1);
|
|
186
186
|
setVisibleCount(pageSize);
|
|
187
187
|
}, () => {
|
|
188
188
|
if (!isCancelled) setStatus("error");
|
|
@@ -261,7 +261,10 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
261
261
|
event.preventDefault();
|
|
262
262
|
const delta = event.key === "ArrowDown" ? 1 : -1;
|
|
263
263
|
movedByKeyboard.current = true;
|
|
264
|
-
setActiveIndex((index) =>
|
|
264
|
+
setActiveIndex((index) => {
|
|
265
|
+
if (index < 0) return delta > 0 ? 0 : hits.length - 1;
|
|
266
|
+
return (index + delta + hits.length) % hits.length;
|
|
267
|
+
});
|
|
265
268
|
return;
|
|
266
269
|
}
|
|
267
270
|
if (event.key === "Enter") {
|
|
@@ -446,13 +449,16 @@ function SearchResultOption({ hit, id, isActive, setSize, posInSet, onActivate,
|
|
|
446
449
|
event.preventDefault();
|
|
447
450
|
onSelect(hit);
|
|
448
451
|
}
|
|
449
|
-
const body = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
|
|
450
|
-
className: "wave-docs-search-result-
|
|
451
|
-
children:
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
452
|
+
const body = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ResultIcon, { section: hit.href.includes("#") }), /* @__PURE__ */ jsxs("span", {
|
|
453
|
+
className: "wave-docs-search-result-text",
|
|
454
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
455
|
+
className: "wave-docs-search-result-heading",
|
|
456
|
+
children: hit.heading
|
|
457
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
458
|
+
className: "wave-docs-search-result-location",
|
|
459
|
+
"aria-hidden": "true",
|
|
460
|
+
children: toDisplaySegments(hit.href).map((segment, index, all) => /* @__PURE__ */ jsxs(Fragment, { children: [index === 0 ? null : /* @__PURE__ */ jsx(PathChevron, {}), segment] }, all.slice(0, index + 1).join("/")))
|
|
461
|
+
})]
|
|
456
462
|
})] });
|
|
457
463
|
return /* @__PURE__ */ jsx("div", {
|
|
458
464
|
id,
|
|
@@ -599,9 +605,10 @@ function isSearchHit(hit) {
|
|
|
599
605
|
* Display only. `hit.href` keeps the anchor, so the link still deep-links to
|
|
600
606
|
* the section — that is the whole point of section-scoped records.
|
|
601
607
|
*/
|
|
602
|
-
function
|
|
608
|
+
function toDisplaySegments(href) {
|
|
603
609
|
const hash = href.indexOf("#");
|
|
604
|
-
|
|
610
|
+
const segments = (hash === -1 ? href : href.slice(0, hash)).split("/").filter(Boolean);
|
|
611
|
+
return segments.length === 0 ? ["/"] : segments;
|
|
605
612
|
}
|
|
606
613
|
/**
|
|
607
614
|
* What a result is called when it is read aloud.
|
|
@@ -618,6 +625,71 @@ function toDisplayPath(href) {
|
|
|
618
625
|
* A page's own record carries `heading === title` and no ancestors, so its name
|
|
619
626
|
* is the heading alone — "Styling, Styling" is not a path, it is a stutter.
|
|
620
627
|
*/
|
|
628
|
+
/**
|
|
629
|
+
* A page or a section, as one Lucide glyph.
|
|
630
|
+
*
|
|
631
|
+
* ⚠️ THE PATHS ARE WRITTEN OUT RATHER THAN IMPORTED FROM THE SIDEBAR, WHICH
|
|
632
|
+
* HAS THE SAME PAGE ICON. `NAV_ICON_PATHS` lives in a different client
|
|
633
|
+
* component, and importing across the two would pull the whole navigation
|
|
634
|
+
* tree into the search dialog's bundle to reuse one string. The duplication is
|
|
635
|
+
* two path strings; the alternative is kilobytes.
|
|
636
|
+
*/
|
|
637
|
+
const RESULT_ICON_PATHS = {
|
|
638
|
+
page: ["M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z", "M14 2v4a2 2 0 0 0 2 2h4"],
|
|
639
|
+
section: [
|
|
640
|
+
"M4 9h16",
|
|
641
|
+
"M4 15h16",
|
|
642
|
+
"m10 3-2 18",
|
|
643
|
+
"m16 3-2 18"
|
|
644
|
+
]
|
|
645
|
+
};
|
|
646
|
+
/**
|
|
647
|
+
* The separator between two route segments.
|
|
648
|
+
*
|
|
649
|
+
* The same path the sidebar's group toggle and the pager draw, at the same
|
|
650
|
+
* `1rem` they draw it. Decorative, and inside a span that is already
|
|
651
|
+
* `aria-hidden` — the route is announced as words by `spokenName`, never read
|
|
652
|
+
* out as punctuation.
|
|
653
|
+
*
|
|
654
|
+
* ⚠️ THE `viewBox` IS CROPPED HORIZONTALLY AND THAT IS NOT A DIFFERENT GLYPH.
|
|
655
|
+
* `d` is Lucide's chevron unchanged and `8 0 8 24` keeps its vertical grid, so
|
|
656
|
+
* the stroke ratio and the ink are exactly what the sidebar and the pager
|
|
657
|
+
* paint. What it drops is the padding either side that the path never reaches:
|
|
658
|
+
* on the full grid a 24-unit box held six units of chevron, so five-sixths of
|
|
659
|
+
* this element was air and the stylesheet had to subtract it back with
|
|
660
|
+
* negative margins. Cropped, the box is the ink and the spacing beside it is
|
|
661
|
+
* one positive value. See `.wave-docs-search-result-sep`.
|
|
662
|
+
*/
|
|
663
|
+
function PathChevron() {
|
|
664
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
665
|
+
className: "wave-docs-search-result-sep",
|
|
666
|
+
"aria-hidden": "true",
|
|
667
|
+
focusable: "false",
|
|
668
|
+
viewBox: "8 5 8 14",
|
|
669
|
+
fill: "none",
|
|
670
|
+
stroke: "currentColor",
|
|
671
|
+
strokeWidth: "2",
|
|
672
|
+
strokeLinecap: "round",
|
|
673
|
+
strokeLinejoin: "round",
|
|
674
|
+
children: /* @__PURE__ */ jsx("path", { d: "m9 18 6-6-6-6" })
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
function ResultIcon({ section }) {
|
|
678
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
679
|
+
className: "wave-docs-search-result-icon",
|
|
680
|
+
"aria-hidden": "true",
|
|
681
|
+
focusable: "false",
|
|
682
|
+
viewBox: "0 0 24 24",
|
|
683
|
+
width: "16",
|
|
684
|
+
height: "16",
|
|
685
|
+
fill: "none",
|
|
686
|
+
stroke: "currentColor",
|
|
687
|
+
strokeWidth: "2",
|
|
688
|
+
strokeLinecap: "round",
|
|
689
|
+
strokeLinejoin: "round",
|
|
690
|
+
children: RESULT_ICON_PATHS[section ? "section" : "page"].map((d) => /* @__PURE__ */ jsx("path", { d }, d))
|
|
691
|
+
});
|
|
692
|
+
}
|
|
621
693
|
function spokenName(hit) {
|
|
622
694
|
if (hit.ancestors.length === 0 && hit.heading === hit.title) return hit.heading;
|
|
623
695
|
return [
|
package/dist/styles.css
CHANGED
|
@@ -2173,7 +2173,16 @@
|
|
|
2173
2173
|
*/
|
|
2174
2174
|
.wave-docs-sidebar__link,
|
|
2175
2175
|
.wave-docs-sidebar__group-button,
|
|
2176
|
-
.wave-docs-sidebar__group-toggle
|
|
2176
|
+
.wave-docs-sidebar__group-toggle,
|
|
2177
|
+
/*
|
|
2178
|
+
* A search result is the same object as a sidebar row — a link that tints
|
|
2179
|
+
* under the pointer — so it eases the same way. Moving over a result
|
|
2180
|
+
* *activates* it, which is why there is no separate hover rule to ease:
|
|
2181
|
+
* the state below is both.
|
|
2182
|
+
*/
|
|
2183
|
+
.wave-docs-search-result,
|
|
2184
|
+
.wave-docs-search-result-heading,
|
|
2185
|
+
.wave-docs-search-result-icon {
|
|
2177
2186
|
transition:
|
|
2178
2187
|
background-color 150ms ease-out,
|
|
2179
2188
|
color 150ms ease-out;
|
|
@@ -3263,7 +3272,17 @@
|
|
|
3263
3272
|
flex: none;
|
|
3264
3273
|
box-sizing: border-box;
|
|
3265
3274
|
width: var(--wave-docs-sidebar-width);
|
|
3266
|
-
|
|
3275
|
+
/*
|
|
3276
|
+
* ⚠️ NO PADDING AND NO OVERFLOW HERE — BOTH MOVED INTO
|
|
3277
|
+
* `.wave-docs-layout__sidebar-scroll`, AND THAT SPLIT IS THE POINT.
|
|
3278
|
+
*
|
|
3279
|
+
* This panel is now an ordinary positioned box that happens to be sticky,
|
|
3280
|
+
* so the edge shadows below resolve against it and have nothing to scroll
|
|
3281
|
+
* with. While it was the scroll container itself they had to be
|
|
3282
|
+
* `position: sticky` and cancel its padding by hand, and every version of
|
|
3283
|
+
* that fought the scroller rather than standing outside it.
|
|
3284
|
+
*/
|
|
3285
|
+
overflow: hidden;
|
|
3267
3286
|
border: 0;
|
|
3268
3287
|
/*
|
|
3269
3288
|
* Cover mode only, and it is a different thing from the divider in push
|
|
@@ -3279,10 +3298,6 @@
|
|
|
3279
3298
|
position: sticky;
|
|
3280
3299
|
top: var(--wave-docs-chrome-offset);
|
|
3281
3300
|
height: calc(100dvh - var(--wave-docs-chrome-offset));
|
|
3282
|
-
overflow-y: auto;
|
|
3283
|
-
/* Without this, reaching the end of the nav keeps scrolling — the article
|
|
3284
|
-
* jumps under the reader's cursor while they are looking at the sidebar. */
|
|
3285
|
-
overscroll-behavior: contain;
|
|
3286
3301
|
/*
|
|
3287
3302
|
* ⚠️ NO `scrollbar-gutter: stable`, AND IT USED TO HAVE ONE.
|
|
3288
3303
|
*
|
|
@@ -3299,11 +3314,196 @@
|
|
|
3299
3314
|
*
|
|
3300
3315
|
* `thin`, so the scrollbar that does appear is narrow, and coloured rather
|
|
3301
3316
|
* than left as the UA's default slab against the panel.
|
|
3317
|
+
*
|
|
3318
|
+
* ⚠️ AND IT IS HIDDEN ONLY WHERE SOMETHING REPLACES IT — see the `@supports`
|
|
3319
|
+
* block below. A bar is the one cue that a column has more below it, so
|
|
3320
|
+
* taking it away before the shadow exists trades a slab for nothing.
|
|
3302
3321
|
*/
|
|
3303
3322
|
scrollbar-width: thin;
|
|
3304
3323
|
scrollbar-color: var(--wave-docs-border-strong) transparent;
|
|
3305
3324
|
}
|
|
3306
3325
|
|
|
3326
|
+
.wave-docs-layout__sidebar-scroll {
|
|
3327
|
+
height: 100%;
|
|
3328
|
+
overflow-y: auto;
|
|
3329
|
+
/* Without this, reaching the end of the nav keeps scrolling — the article
|
|
3330
|
+
* jumps under the reader's cursor while they are looking at the sidebar. */
|
|
3331
|
+
overscroll-behavior: contain;
|
|
3332
|
+
padding: 2rem 1rem;
|
|
3333
|
+
scrollbar-width: inherit;
|
|
3334
|
+
scrollbar-color: inherit;
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
/*
|
|
3338
|
+
* ⚠️ ABSOLUTE AGAINST THE PANEL, WHICH IS NOT THE SCROLLER — AND THAT IS THE
|
|
3339
|
+
* WHOLE REASON THIS FINALLY HOLDS STILL.
|
|
3340
|
+
*
|
|
3341
|
+
* These were `position: sticky` inside the scroll container, cancelling its
|
|
3342
|
+
* padding by hand, and they still read as moving. An absolutely positioned
|
|
3343
|
+
* child of a scroll container is laid out against that container's padding
|
|
3344
|
+
* box and joins its scrollable overflow, so nothing placed inside the
|
|
3345
|
+
* scroller is ever truly pinned to the panel; sticky only clamps it, and a
|
|
3346
|
+
* clamp is still a thing that travels until it catches.
|
|
3347
|
+
*
|
|
3348
|
+
* With the overflow moved to `.wave-docs-layout__sidebar-scroll`, the panel is
|
|
3349
|
+
* an ordinary positioned box. `top: 0` and `bottom: 0` are its own edges, and
|
|
3350
|
+
* there is no scroll for these to have a position within.
|
|
3351
|
+
*
|
|
3352
|
+
* ⚠️ AND THE SHADOW STILL BELONGS TO THE PANEL RATHER THAN THE TREE, because
|
|
3353
|
+
* `.wave-docs-sidebar` paints an opaque ground — it is in the theme's opt-in
|
|
3354
|
+
* rule, and the shell has to be one surface — so a background on the scroller
|
|
3355
|
+
* would be painted underneath it. Measured 255/255 when it was.
|
|
3356
|
+
*/
|
|
3357
|
+
.wave-docs-layout__sidebar-nav::before,
|
|
3358
|
+
.wave-docs-layout__sidebar-nav::after {
|
|
3359
|
+
content: '';
|
|
3360
|
+
position: absolute;
|
|
3361
|
+
inset-inline: 0;
|
|
3362
|
+
z-index: 1;
|
|
3363
|
+
height: 0.75rem;
|
|
3364
|
+
opacity: 0;
|
|
3365
|
+
pointer-events: none;
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
.wave-docs-layout__sidebar-nav::before {
|
|
3369
|
+
top: 0;
|
|
3370
|
+
/*
|
|
3371
|
+
* Radial, the shape the table and the search list use: strongest against
|
|
3372
|
+
* the edge it belongs to and falling away from it, rather than a flat ramp
|
|
3373
|
+
* across the band.
|
|
3374
|
+
*/
|
|
3375
|
+
background: radial-gradient(
|
|
3376
|
+
farthest-side at 50% 0,
|
|
3377
|
+
var(--wave-docs-scroll-shadow),
|
|
3378
|
+
transparent
|
|
3379
|
+
);
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
.wave-docs-layout__sidebar-nav::after {
|
|
3383
|
+
bottom: 0;
|
|
3384
|
+
background: radial-gradient(
|
|
3385
|
+
farthest-side at 50% 100%,
|
|
3386
|
+
var(--wave-docs-scroll-shadow),
|
|
3387
|
+
transparent
|
|
3388
|
+
);
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
/*
|
|
3392
|
+
* ⚠️ AN INACTIVE TIMELINE IS WHAT MAKES THIS CORRECT. A scroll timeline is
|
|
3393
|
+
* inactive when its container has no scrollable overflow in the axis, and an
|
|
3394
|
+
* animation with an inactive timeline does not apply — so `opacity: 0` wins
|
|
3395
|
+
* and a nav that fits shows nothing at all. No query asks whether it scrolls;
|
|
3396
|
+
* the answer falls out of whether there is anything to scroll.
|
|
3397
|
+
*
|
|
3398
|
+
* ⚠️ `nearest`, NOT `self`: the animation is on a pseudo-element *inside* the
|
|
3399
|
+
* scroll container rather than on the container itself, and `self` would name
|
|
3400
|
+
* the pseudo — inactive at every height, which looks exactly like the feature
|
|
3401
|
+
* being unsupported.
|
|
3402
|
+
*
|
|
3403
|
+
* The scrollbar is hidden in here and nowhere else. Where this does not run —
|
|
3404
|
+
* Firefox, until scroll-driven animations ship — the thin bar above stays and
|
|
3405
|
+
* a reader keeps the cue rather than losing both.
|
|
3406
|
+
*/
|
|
3407
|
+
@supports (animation-timeline: scroll()) {
|
|
3408
|
+
.wave-docs-layout__sidebar-scroll {
|
|
3409
|
+
scrollbar-width: none;
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
.wave-docs-layout__sidebar-scroll::-webkit-scrollbar {
|
|
3413
|
+
display: none;
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
/*
|
|
3417
|
+
* ⚠️ A NAMED TIMELINE, AND `scroll(nearest block)` WAS A REAL BUG RATHER
|
|
3418
|
+
* THAN A TIDIER SPELLING.
|
|
3419
|
+
*
|
|
3420
|
+
* `nearest` means the nearest ancestor *scroll container*, and a box is
|
|
3421
|
+
* only one when it actually has scrollable overflow. This nav is a screen
|
|
3422
|
+
* tall and on most sites its tree fits — measured `scrollHeight -
|
|
3423
|
+
* clientHeight === 0` at 1440×900 on this very site — so `nearest` walked
|
|
3424
|
+
* straight past it and found the document, which always scrolls.
|
|
3425
|
+
*
|
|
3426
|
+
* The result was the shadow keyed to the *page*: it faded in as the reader
|
|
3427
|
+
* scrolled the article, on a navigation with nothing hidden, while the
|
|
3428
|
+
* sticky panel was still travelling to its pinned position. Appearing and
|
|
3429
|
+
* moving at once, which is precisely what it looked like.
|
|
3430
|
+
*
|
|
3431
|
+
* Named, it can only ever be this element's own scroll. And the
|
|
3432
|
+
* inactive-timeline rule then does the right thing for free: a nav that
|
|
3433
|
+
* fits declares a timeline with no scroll range, the animation does not
|
|
3434
|
+
* apply, and `opacity: 0` wins.
|
|
3435
|
+
*/
|
|
3436
|
+
/*
|
|
3437
|
+
* ⚠️ THE TIMELINE IS DECLARED ON THE SCROLLER AND *SCOPED* UP TO THE PANEL,
|
|
3438
|
+
* because the two are different elements now. A `scroll-timeline` name is
|
|
3439
|
+
* visible to the declaring element's descendants, and these pseudo-elements
|
|
3440
|
+
* belong to its parent — so without `timeline-scope` they would not find it
|
|
3441
|
+
* and would sit at `opacity: 0` for ever, looking exactly like the feature
|
|
3442
|
+
* being unsupported.
|
|
3443
|
+
*/
|
|
3444
|
+
.wave-docs-layout__sidebar-nav {
|
|
3445
|
+
timeline-scope: --wave-docs-sidebar-nav-scroll;
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
.wave-docs-layout__sidebar-scroll {
|
|
3449
|
+
scroll-timeline: --wave-docs-sidebar-nav-scroll block;
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
.wave-docs-layout__sidebar-nav::before,
|
|
3453
|
+
.wave-docs-layout__sidebar-nav::after {
|
|
3454
|
+
animation-timeline: --wave-docs-sidebar-nav-scroll;
|
|
3455
|
+
animation-timing-function: linear;
|
|
3456
|
+
animation-fill-mode: both;
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
/*
|
|
3460
|
+
* ⚠️ THE RANGE IS A LENGTH, NOT THE WHOLE SCROLL — AND THAT IS WHAT STOPS
|
|
3461
|
+
* THIS RAMPING WHILE THE READER SCROLLS.
|
|
3462
|
+
*
|
|
3463
|
+
* `wave-docs-scroll-shadow-start` shapes its fade in *percentages*, which
|
|
3464
|
+
* are percentages of the container's total scroll range. That is fine on a
|
|
3465
|
+
* table, which scrolls a few hundred pixels; on a navigation it is not. A
|
|
3466
|
+
* nav with 1000px of scroll turns the same `2%…8%` into 20px…80px, so the
|
|
3467
|
+
* shadow spends eighty pixels of scrolling getting to full strength —
|
|
3468
|
+
* arriving gradually, which reads exactly like it is moving with the
|
|
3469
|
+
* content rather than pinned to the edge.
|
|
3470
|
+
*
|
|
3471
|
+
* A fixed `1rem` makes it the same short distance whatever the nav's
|
|
3472
|
+
* height: present almost at once, and identical on a six-page site and a
|
|
3473
|
+
* three-hundred-page one. The keyframes are plain fades because the shaping
|
|
3474
|
+
* now lives in the range.
|
|
3475
|
+
*/
|
|
3476
|
+
.wave-docs-layout__sidebar-nav::before {
|
|
3477
|
+
animation-name: wave-docs-scroll-shadow-appear;
|
|
3478
|
+
animation-range: 0 1rem;
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
.wave-docs-layout__sidebar-nav::after {
|
|
3482
|
+
animation-name: wave-docs-scroll-shadow-vanish;
|
|
3483
|
+
animation-range: calc(100% - 1rem) 100%;
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
/* Absent at the start edge, present as soon as it is not. */
|
|
3488
|
+
@keyframes wave-docs-scroll-shadow-appear {
|
|
3489
|
+
from {
|
|
3490
|
+
opacity: 0;
|
|
3491
|
+
}
|
|
3492
|
+
to {
|
|
3493
|
+
opacity: 1;
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
/* Present until the end edge is reached. */
|
|
3498
|
+
@keyframes wave-docs-scroll-shadow-vanish {
|
|
3499
|
+
from {
|
|
3500
|
+
opacity: 1;
|
|
3501
|
+
}
|
|
3502
|
+
to {
|
|
3503
|
+
opacity: 0;
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3307
3507
|
/* First child of the navigation, above the tree. */
|
|
3308
3508
|
.wave-docs-layout__search {
|
|
3309
3509
|
margin-block-end: 1rem;
|
|
@@ -3913,34 +4113,87 @@
|
|
|
3913
4113
|
box-shadow: 0 24px 48px -12px var(--wave-docs-shadow);
|
|
3914
4114
|
}
|
|
3915
4115
|
|
|
4116
|
+
/*
|
|
4117
|
+
* ⚠️ A FIELD SET INTO THE DIALOG, AND IT WAS A FULL-BLEED BAND WITH TWO
|
|
4118
|
+
* ROUNDED CORNERS AND A RULE UNDER IT.
|
|
4119
|
+
*
|
|
4120
|
+
* Flush to the frame it could only round the corners it shared with the
|
|
4121
|
+
* dialog, so it read as part of the chrome rather than as the one control in
|
|
4122
|
+
* it. Inset by the same margin the results list uses, it is a box like they
|
|
4123
|
+
* are, and the rule under it is redundant — the gap does that work.
|
|
4124
|
+
*
|
|
4125
|
+
* ⚠️ AND THE `- 1px` IS THIS BOX'S OWN BORDER, WHICH IS WHAT PUTS ITS GLYPH
|
|
4126
|
+
* ON THE SAME COLUMN AS EVERYTHING ELSE. Content inside a bordered box starts
|
|
4127
|
+
* a border further in than content inside an unbordered one, so equal padding
|
|
4128
|
+
* misses by exactly that. The same subtraction `--wave-docs-panel-inset`
|
|
4129
|
+
* documents, for the same reason.
|
|
4130
|
+
*/
|
|
3916
4131
|
.wave-docs-search-input-row {
|
|
3917
4132
|
display: flex;
|
|
3918
4133
|
align-items: center;
|
|
3919
4134
|
gap: 0.5rem;
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
*
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
4135
|
+
margin: 0.375rem;
|
|
4136
|
+
/*
|
|
4137
|
+
* ⚠️ THE TRIGGER, EXPANDED — SAME BORDER, SAME FILL, SAME RADIUS.
|
|
4138
|
+
*
|
|
4139
|
+
* A reader clicks a bordered, filled control and lands in this dialog, so
|
|
4140
|
+
* the thing their cursor is now in should be the same object. It was a
|
|
4141
|
+
* border with no fill (two frames a few pixels apart), then a fill with no
|
|
4142
|
+
* border (a tinted band, not a control); the trigger has always been both,
|
|
4143
|
+
* and both together are what reads as a field.
|
|
4144
|
+
*
|
|
4145
|
+
* ⚠️ THE PADDING IS NOT COPIED WITH THEM. The trigger pays
|
|
4146
|
+
* `calc(0.5rem - 1px)` because it is a compact control in a sidebar; this
|
|
4147
|
+
* pays `calc(0.75rem - 1px)` because its glyph has to land on the column
|
|
4148
|
+
* the results and the footer sit on. The `- 1px` is the border either way:
|
|
4149
|
+
* content inside a bordered box starts a border further in.
|
|
4150
|
+
*
|
|
4151
|
+
* ⚠️ AND THE RADIUS IS THE TRIGGER'S TIER, NOT THE ROWS'. Both are controls
|
|
4152
|
+
* on the base tier; a result row is a list item on `-sm`. So the field is
|
|
4153
|
+
* 16px and the rows below it are 12px, which is the tier system doing what
|
|
4154
|
+
* it says rather than two boxes disagreeing.
|
|
4155
|
+
*/
|
|
4156
|
+
padding: 0.5rem calc(0.75rem - 1px);
|
|
4157
|
+
border: 1px solid var(--wave-docs-border);
|
|
4158
|
+
border-radius: var(--wave-docs-radius);
|
|
4159
|
+
background: var(--wave-docs-bg-subtle);
|
|
4160
|
+
}
|
|
4161
|
+
|
|
4162
|
+
/*
|
|
4163
|
+
* ⚠️ THE FIELD DARKENS ITS EDGE ON FOCUS — IT DOES NOT DRAW AN ACCENT RING,
|
|
4164
|
+
* AND IT CANNOT, BECAUSE IT COULD NEVER PUT ONE DOWN.
|
|
4165
|
+
*
|
|
4166
|
+
* A text input matches `:focus-visible` whenever it is focused, however
|
|
4167
|
+
* focus arrived — that is the spec, not a heuristic — and this dialog focuses
|
|
4168
|
+
* its input the moment it opens. So a 2px accent ring here is not a state, it
|
|
4169
|
+
* is the field's permanent appearance, and the field stops looking like the
|
|
4170
|
+
* bordered grey control the reader clicked to get here.
|
|
4171
|
+
*
|
|
4172
|
+
* ⚠️ AND THE INDICATOR IS NOT LOST WITH IT, BECAUSE A TEXT FIELD HAS ONE OF
|
|
4173
|
+
* ITS OWN. The caret is the platform's focus indication for a text box, it is
|
|
4174
|
+
* in this field the whole time the dialog is open, and it is the thing a
|
|
4175
|
+
* reader looks for when they want to know where typing goes. That is a
|
|
4176
|
+
* different argument from the one this rule used to reject — "the dialog
|
|
4177
|
+
* frame is the indicator" was a *static* border that looked identical focused
|
|
4178
|
+
* and unfocused, indicating nothing.
|
|
4179
|
+
*
|
|
4180
|
+
* The edge still changes, to the same `border-strong` the trigger takes under
|
|
4181
|
+
* the pointer, so the field is the trigger in both of its states.
|
|
4182
|
+
*/
|
|
3935
4183
|
.wave-docs-search-input-row:has(.wave-docs-search-input:focus-visible) {
|
|
3936
|
-
|
|
3937
|
-
outline-offset: -2px;
|
|
4184
|
+
border-color: var(--wave-docs-border-strong);
|
|
3938
4185
|
}
|
|
3939
4186
|
|
|
3940
4187
|
.wave-docs-search-input {
|
|
3941
4188
|
flex: 1 1 auto;
|
|
3942
4189
|
min-width: 0;
|
|
3943
|
-
|
|
4190
|
+
/*
|
|
4191
|
+
* ⚠️ NO INLINE PADDING, BECAUSE THIS BOX'S EDGE IS A COLUMN THE RESULTS
|
|
4192
|
+
* MEASURE TO. Four pixels here put the typed text four pixels right of
|
|
4193
|
+
* every result heading below it, which is the sort of misalignment that
|
|
4194
|
+
* looks like a bug in the list rather than a padding on the field.
|
|
4195
|
+
*/
|
|
4196
|
+
padding: 0.375rem 0;
|
|
3944
4197
|
border: 0;
|
|
3945
4198
|
background: none;
|
|
3946
4199
|
color: var(--wave-docs-fg);
|
|
@@ -3973,7 +4226,18 @@
|
|
|
3973
4226
|
display: flex;
|
|
3974
4227
|
align-items: center;
|
|
3975
4228
|
gap: 1rem;
|
|
3976
|
-
|
|
4229
|
+
/*
|
|
4230
|
+
* ⚠️ THE COLUMN EVERY OTHER ROW IN THIS DIALOG LINES UP ON, AND IT IS THE
|
|
4231
|
+
* ANCHOR RATHER THAN A FOLLOWER. The key caps are the one row whose left
|
|
4232
|
+
* edge is a drawn object, so the input's glyph and the results' text are
|
|
4233
|
+
* measured to it: `0.375rem` of inset plus `0.75rem` of padding is the same
|
|
4234
|
+
* 18px this pays in one go.
|
|
4235
|
+
*
|
|
4236
|
+
* Boxes, not ink. A cap's arrow sits its own border and `0.4em` of padding
|
|
4237
|
+
* inside the cap, so aligning the *glyphs* would put every other row on a
|
|
4238
|
+
* column that moves whenever the footer's font size does.
|
|
4239
|
+
*/
|
|
4240
|
+
padding: 0.5rem 1.125rem;
|
|
3977
4241
|
border-block-start: 1px solid var(--wave-docs-border);
|
|
3978
4242
|
color: var(--wave-docs-fg-subtle);
|
|
3979
4243
|
font-size: 0.75rem;
|
|
@@ -4059,7 +4323,75 @@
|
|
|
4059
4323
|
.wave-docs-search-results {
|
|
4060
4324
|
flex: 1 1 auto;
|
|
4061
4325
|
overflow-y: auto;
|
|
4062
|
-
|
|
4326
|
+
/*
|
|
4327
|
+
* ⚠️ THE TABLE'S SCROLL SHADOW, TURNED ON ITS SIDE — AND IT REPLACED A
|
|
4328
|
+
* MASK, WHICH WAS THE WRONG TOOL TWICE OVER.
|
|
4329
|
+
*
|
|
4330
|
+
* A mask fades content to *transparent*, so what showed through was the
|
|
4331
|
+
* dialog's own white: a hole rather than a shadow. And it cannot be
|
|
4332
|
+
* conditional — CSS has no way to ask whether there is anything above to
|
|
4333
|
+
* scroll to — so the first and last rows were softened even at rest.
|
|
4334
|
+
*
|
|
4335
|
+
* Four gradients, exactly as `.wave-docs-table-scroll` does it. The two
|
|
4336
|
+
* `local` covers are painted in the surface colour and travel *with* the
|
|
4337
|
+
* rows, so each sits over its shadow only while that edge is at rest; the
|
|
4338
|
+
* two `scroll` shadows are pinned to the box. A grey edge appears on
|
|
4339
|
+
* precisely the side that has rows off-screen, with no listener, no state
|
|
4340
|
+
* and no hydration.
|
|
4341
|
+
*/
|
|
4342
|
+
background:
|
|
4343
|
+
linear-gradient(to bottom, var(--wave-docs-bg) 30%, transparent) top /
|
|
4344
|
+
100% 2rem no-repeat local,
|
|
4345
|
+
linear-gradient(to top, var(--wave-docs-bg) 30%, transparent) bottom /
|
|
4346
|
+
100% 2rem no-repeat local,
|
|
4347
|
+
radial-gradient(
|
|
4348
|
+
farthest-side at 50% 0,
|
|
4349
|
+
var(--wave-docs-scroll-shadow),
|
|
4350
|
+
transparent
|
|
4351
|
+
)
|
|
4352
|
+
top / 100% 0.75rem no-repeat scroll,
|
|
4353
|
+
radial-gradient(
|
|
4354
|
+
farthest-side at 50% 100%,
|
|
4355
|
+
var(--wave-docs-scroll-shadow),
|
|
4356
|
+
transparent
|
|
4357
|
+
)
|
|
4358
|
+
bottom / 100% 0.75rem no-repeat scroll;
|
|
4359
|
+
/*
|
|
4360
|
+
* ⚠️ NO PADDING ON TOP, BECAUSE THE FIELD'S MARGIN IS ALREADY THAT GAP.
|
|
4361
|
+
* Both paid it, so the space between the input and the first result was
|
|
4362
|
+
* twice the space between the input and the top of the dialog — a
|
|
4363
|
+
* doubled gap in the one place a reader's eye travels on every keystroke.
|
|
4364
|
+
*
|
|
4365
|
+
* Kept on the *bottom*, where nothing else pays it, and on the sides,
|
|
4366
|
+
* where it is what lines a row's box up with the field's.
|
|
4367
|
+
*
|
|
4368
|
+
* ⚠️ AND IT IS THE FIELD THAT KEEPS ITS MARGIN RATHER THAN THIS THAT KEEPS
|
|
4369
|
+
* ITS PADDING. An empty result list is `display: none`, so a gap paid from
|
|
4370
|
+
* here would vanish on the query that matches nothing and leave the field
|
|
4371
|
+
* sitting on the footer's rule.
|
|
4372
|
+
*/
|
|
4373
|
+
padding: 0 0.375rem 0.375rem;
|
|
4374
|
+
/*
|
|
4375
|
+
* ⚠️ NO SCROLLBAR, AND NO CSS WAY TO SHOW ONE ONLY WHILE SCROLLING.
|
|
4376
|
+
*
|
|
4377
|
+
* That behaviour is the platform's: macOS draws overlay scrollbars that
|
|
4378
|
+
* fade in on scroll and out after it, and this list gets it for free there.
|
|
4379
|
+
* Windows and Linux draw a classic one that is always present, and nothing
|
|
4380
|
+
* in CSS can make it come and go — the only ways are a JavaScript timer
|
|
4381
|
+
* that toggles a class, or a scrollbar drawn from scratch in script, which
|
|
4382
|
+
* is what a `ScrollArea` component is. Neither belongs in a package whose
|
|
4383
|
+
* whole argument is what it does *not* ship to a reader.
|
|
4384
|
+
*
|
|
4385
|
+
* So it is hidden, which is what `@waveso/app` does with `.scrollbar-none`.
|
|
4386
|
+
* What replaces it here is not a fade but the keyboard: the footer says
|
|
4387
|
+
* `↑ ↓ Select`, the list is driven by `aria-activedescendant`, and arrowing
|
|
4388
|
+
* past the last visible row scrolls it and loads the next page.
|
|
4389
|
+
*/
|
|
4390
|
+
scrollbar-width: none;
|
|
4391
|
+
}
|
|
4392
|
+
|
|
4393
|
+
.wave-docs-search-results::-webkit-scrollbar {
|
|
4394
|
+
display: none;
|
|
4063
4395
|
}
|
|
4064
4396
|
|
|
4065
4397
|
/*
|
|
@@ -4076,29 +4408,104 @@
|
|
|
4076
4408
|
border-radius: var(--wave-docs-radius-sm);
|
|
4077
4409
|
}
|
|
4078
4410
|
|
|
4079
|
-
/*
|
|
4080
|
-
*
|
|
4081
|
-
*
|
|
4082
|
-
*
|
|
4083
|
-
*
|
|
4084
|
-
*
|
|
4085
|
-
*
|
|
4411
|
+
/*
|
|
4412
|
+
* The active row, driven by `aria-selected` through the class the dialog
|
|
4413
|
+
* sets — so the visual state and the announced state cannot drift apart.
|
|
4414
|
+
* Pointer and keyboard land on the same state: moving over a row activates
|
|
4415
|
+
* it, so hovering *is* this.
|
|
4416
|
+
*
|
|
4417
|
+
* ⚠️ A TINT AND AN INK, AND NO EDGE IN ANY STATE. It was a 2px accent ring,
|
|
4418
|
+
* and briefly the trigger's border pair. The ring read as a component
|
|
4419
|
+
* borrowed from somewhere else and one that comes and goes as a reader
|
|
4420
|
+
* arrows is worse than one that never moves; bordering *every* row to make
|
|
4421
|
+
* the active one's edge legible turned a list into a stack of cards. The
|
|
4422
|
+
* field above wears the trigger's border because the field is a control; a
|
|
4423
|
+
* row is a list item, and its state is a colour.
|
|
4424
|
+
*
|
|
4425
|
+
* ⚠️ THE INK IS NOT DECORATION — IT IS WHAT KEEPS THE STATE PERCEIVABLE. A
|
|
4426
|
+
* tint alone is 1.12:1 light and 1.19:1 dark, under the 3:1 WCAG 1.4.11 asks
|
|
4427
|
+
* of a state indicator. `accent` on `accent-subtle` is 4.60:1 / 6.30:1, which
|
|
4428
|
+
* is text contrast rather than non-text and is the same pair the sidebar's
|
|
4429
|
+
* current-page row has always shipped. What must not happen is the tint going
|
|
4430
|
+
* back to carrying this alone.
|
|
4431
|
+
*/
|
|
4086
4432
|
.wave-docs-search-result-active {
|
|
4087
4433
|
background: var(--wave-docs-accent-subtle);
|
|
4088
|
-
outline: 2px solid var(--wave-docs-accent);
|
|
4089
|
-
outline-offset: -2px;
|
|
4090
4434
|
}
|
|
4091
4435
|
|
|
4436
|
+
.wave-docs-search-result-active .wave-docs-search-result-heading,
|
|
4437
|
+
.wave-docs-search-result-active .wave-docs-search-result-icon {
|
|
4438
|
+
color: var(--wave-docs-accent);
|
|
4439
|
+
}
|
|
4440
|
+
|
|
4441
|
+
/*
|
|
4442
|
+
* ⚠️ A ROW OF TWO COLUMNS, AND IT WAS ONE STACKED COLUMN.
|
|
4443
|
+
*
|
|
4444
|
+
* The dialog reads as one grid only if the input and the results agree about
|
|
4445
|
+
* *both* their columns. With no icon, a result's heading sat on the same
|
|
4446
|
+
* column as the input's magnifier while the input's own text sat 24px right
|
|
4447
|
+
* of it — three left edges where a reader expects two, which is what made the
|
|
4448
|
+
* dialog look like stacked levels rather than a list under a field.
|
|
4449
|
+
*
|
|
4450
|
+
* The icon takes the magnifier's column and the text takes the input's, so
|
|
4451
|
+
* the two rows are the same shape and there are exactly two edges.
|
|
4452
|
+
*/
|
|
4092
4453
|
.wave-docs-search-result-link {
|
|
4093
4454
|
display: flex;
|
|
4094
|
-
|
|
4095
|
-
gap
|
|
4096
|
-
|
|
4455
|
+
align-items: flex-start;
|
|
4456
|
+
/* The input's own gap between its glyph and its text. */
|
|
4457
|
+
gap: 0.5rem;
|
|
4458
|
+
/* The list pays `0.375rem` around this, so `0.75rem` here lands the icon
|
|
4459
|
+
* on the dialog's 18px column. See the footer, which is the anchor. */
|
|
4460
|
+
padding: 0.5rem 0.75rem;
|
|
4097
4461
|
border-radius: var(--wave-docs-radius-sm);
|
|
4098
4462
|
color: var(--wave-docs-fg);
|
|
4099
4463
|
text-decoration: none;
|
|
4100
4464
|
}
|
|
4101
4465
|
|
|
4466
|
+
.wave-docs-search-result-icon {
|
|
4467
|
+
flex: none;
|
|
4468
|
+
/*
|
|
4469
|
+
* ⚠️ NUDGED DOWN, NOT CENTRED. `align-items: center` would centre this on
|
|
4470
|
+
* the *two-line* block, so it would sit between the heading and the route
|
|
4471
|
+
* rather than beside the heading — and it would move when a route wrapped.
|
|
4472
|
+
* A 1rem glyph on a 0.9375rem/1.4 line is 5px shorter than the line box, so
|
|
4473
|
+
* half of that puts it on the heading's optical centre and keeps it there.
|
|
4474
|
+
*/
|
|
4475
|
+
margin-block-start: 0.15625rem;
|
|
4476
|
+
color: var(--wave-docs-fg-subtle);
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4479
|
+
.wave-docs-search-result-text {
|
|
4480
|
+
display: flex;
|
|
4481
|
+
flex-direction: column;
|
|
4482
|
+
gap: 0.125rem;
|
|
4483
|
+
/* So a long heading ellipsises inside the row rather than widening it. */
|
|
4484
|
+
min-width: 0;
|
|
4485
|
+
}
|
|
4486
|
+
|
|
4487
|
+
/*
|
|
4488
|
+
* The separator between two route segments — the package's own chevron
|
|
4489
|
+
* rather than a character, so there is no font for it to be missing from.
|
|
4490
|
+
*
|
|
4491
|
+
* The `viewBox` is cropped to the painted ink, so the box is the glyph: it
|
|
4492
|
+
* sits on the baseline the way a letter does, with no alignment to correct.
|
|
4493
|
+
*/
|
|
4494
|
+
.wave-docs-search-result-sep {
|
|
4495
|
+
height: 7px;
|
|
4496
|
+
width: auto;
|
|
4497
|
+
/*
|
|
4498
|
+
* The `viewBox` is exactly the stroke's extent, so the tip lands on the
|
|
4499
|
+
* box's own edge and an SVG clips at that edge by default — the point came
|
|
4500
|
+
* out flat. Nothing here overlaps: what spills is a fraction of a pixel of
|
|
4501
|
+
* anti-aliasing.
|
|
4502
|
+
*/
|
|
4503
|
+
overflow: visible;
|
|
4504
|
+
/* The air either side. */
|
|
4505
|
+
margin-inline: 4px;
|
|
4506
|
+
color: var(--wave-docs-fg-subtle);
|
|
4507
|
+
}
|
|
4508
|
+
|
|
4102
4509
|
.wave-docs-search-result-heading {
|
|
4103
4510
|
font-size: 0.9375rem;
|
|
4104
4511
|
font-weight: 600;
|
|
@@ -4133,10 +4540,21 @@
|
|
|
4133
4540
|
white-space: nowrap;
|
|
4134
4541
|
text-overflow: ellipsis;
|
|
4135
4542
|
color: var(--wave-docs-fg-subtle);
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
*
|
|
4139
|
-
|
|
4543
|
+
/*
|
|
4544
|
+
* The package's sans, the same face as everything else in the dialog.
|
|
4545
|
+
*
|
|
4546
|
+
* ⚠️ IT WAS MONOSPACE, AND THAT IS WHAT MADE THE SEPARATOR UNRELIABLE. A
|
|
4547
|
+
* mono face advances every glyph one cell, so the trail sat on a rigid
|
|
4548
|
+
* grid an icon could not join, and its metrics are whatever the machine
|
|
4549
|
+
* resolves — SF Mono here, Consolas on Windows, Liberation Mono on Linux —
|
|
4550
|
+
* so the cap height the chevron is sized against moved from reader to
|
|
4551
|
+
* reader. Set in the sans, the line has ordinary proportional spacing and
|
|
4552
|
+
* one set of metrics to match.
|
|
4553
|
+
*/
|
|
4554
|
+
font-family: var(--wave-docs-font-sans);
|
|
4555
|
+
/* A notch up from the mono's `0.6875rem`: a monospace face reads larger at
|
|
4556
|
+
* the same size, so holding the number would have shrunk this line. */
|
|
4557
|
+
font-size: 0.75rem;
|
|
4140
4558
|
line-height: 1.5;
|
|
4141
4559
|
}
|
|
4142
4560
|
|
package/package.json
CHANGED