max / makenotwork
| 1 | //! The Askama entry point for the described media picker. |
| 2 | //! |
| 3 | //! Shape 4 of the conversion plan (wiki `mnw-shape-conversion-plans`), step 1. |
| 4 | //! [`Act::fills`](quasi_router::Act::fills) is what expresses the deposit, so |
| 5 | //! `static/media-picker.js` is gone. |
| 6 | //! |
| 7 | //! # What a card says now |
| 8 | //! |
| 9 | //! `Act::new(filename, Action::local()).filling("body", reference)`. The |
| 10 | //! destination is a field name and never a caret: a webview inserts at the |
| 11 | //! selection, and where inside a box a value lands was never the description's |
| 12 | //! to say. The action is [`Destination::Local`](quasi_router::Destination), so |
| 13 | //! a pick makes no request at all — the old picker made none either, and the |
| 14 | //! two round trips this shape does cost are the open and the dismiss. |
| 15 | //! |
| 16 | //! # Three defects the shipped picker has, which this does not |
| 17 | //! |
| 18 | //! Found reading `media-picker.js:83-84` against `MediaFileResponse` |
| 19 | //! (`routes/storage/media.rs:66`), and none of them is a conversion decision — |
| 20 | //! the described version cannot have any of the three, because the value is |
| 21 | //! held once on the server instead of being spelled again in a browser. |
| 22 | //! |
| 23 | //! 1. **Every card is unlabelled.** The JSON field is `filename`; the script |
| 24 | //! reads `f.file_name`, which is `undefined`. So `label.textContent` and |
| 25 | //! `img.alt` are both the empty string on every tile. |
| 26 | //! 2. **The name filter matches nothing.** `card.dataset.name` comes from the |
| 27 | //! same `undefined`, so typing one character hides the entire library. |
| 28 | //! 3. **The inserted reference is wrapped twice.** `markdown_ref` is already |
| 29 | //! `` (`media.rs:109-113`) and `_mediaPickerSelect` |
| 30 | //! inserts `''`, so what lands in the box is |
| 31 | //! `)`. |
| 32 | //! |
| 33 | //! Filed as its own record rather than only fixed here, because (3) means the |
| 34 | //! documents people have already written with this button carry broken image |
| 35 | //! references that no redeploy repairs. |
| 36 | //! |
| 37 | //! # Why only the grid is replaced when a filter moves |
| 38 | //! |
| 39 | //! The plan said the filter controls carry `changes` and the server re-renders |
| 40 | //! the grid as a fragment. It said the fragment is aimed at the modal; it is |
| 41 | //! aimed at [`GRID`] instead, one region further in. Replacing the modal would |
| 42 | //! replace the box being typed into, which takes the caret with it on every |
| 43 | //! keystroke. |
| 44 | //! |
| 45 | //! The name box also asks through a [`Consult`] rather than through `changes`, |
| 46 | //! which is the plan's one other deviation and is the same call |
| 47 | //! [`crate::quasi::discover_typeahead`] made: `changes` fires per keystroke and |
| 48 | //! a media library is a Postgres round trip, where a consult carries the wait |
| 49 | //! that makes it one question per pause. The folder select keeps `changes`, |
| 50 | //! because a select settles rather than being typed into. |
| 51 | //! |
| 52 | //! # The destination has to be unique in the document, and that is new |
| 53 | //! |
| 54 | //! [`Act::fills`] names a [`Field::name`], and a name identifies a field within |
| 55 | //! one description. `Filling::id_prefix` exists because that stops being true |
| 56 | //! when a document holds two copies of one form, and |
| 57 | //! [`crate::quasi::rich_field`] leans on it: five editors, all named `body`, |
| 58 | //! told apart by their ids. |
| 59 | //! |
| 60 | //! So an editor a picker can write into has to be the only `body` in its |
| 61 | //! document. Both surfaces that had a working button already are: |
| 62 | //! `dashboard-blog-editor.html` holds `post-body` alone, and |
| 63 | //! `item_details.html` holds `text-body` alone under that name. The two section |
| 64 | //! editors on that same page were raw `<textarea>` elements with an id and no |
| 65 | //! name at all, which no description can address, so they are described here |
| 66 | //! too and carry names of their own. See `rich_field::named`. |
| 67 | |
| 68 | use Fit; |
| 69 | use declare; |
| 70 | use Act; |
| 71 | use ; |
| 72 | |
| 73 | /// The stem both region ids are built from. |
| 74 | const REGION: &str = "media-picker"; |
| 75 | |
| 76 | /// The region one editor's picker is drawn into, and the empty box its trigger |
| 77 | /// ships beside it. |
| 78 | /// |
| 79 | /// One id for both, so the answer replaces the placeholder rather than nesting |
| 80 | /// inside it, and a dismissal puts the placeholder back. |
| 81 | /// |
| 82 | /// **Per destination, not per page.** `item_details.html` carries three of |
| 83 | /// these buttons, and one shared id would be three elements answering to it — |
| 84 | /// which is a duplicate id whichever of them htmx then found first. A picker |
| 85 | /// belongs to the box it was opened for, so its region is named after that box. |
| 86 | |
| 87 | |
| 88 | format! |
| 89 | |
| 90 | |
| 91 | /// The panel inside the scrim. |
| 92 | /// |
| 93 | /// The modal region is the scrim — fixed, covering the viewport, and what stops |
| 94 | /// the page behind it being pressed — and this is the box that sits in the |
| 95 | /// middle of it. Two regions because a scrim and a panel are two boxes, and one |
| 96 | /// element cannot be both without the panel's contents being laid out across |
| 97 | /// the whole viewport. |
| 98 | |
| 99 | |
| 100 | format! |
| 101 | |
| 102 | |
| 103 | /// The region inside one picker that holds the tiles. |
| 104 | /// |
| 105 | /// Addressed separately from [`region_id`] so a filter answer leaves the filter |
| 106 | /// controls, and the caret in them, alone. |
| 107 | |
| 108 | |
| 109 | format! |
| 110 | |
| 111 | |
| 112 | /// Whether a destination name is one this module will build ids and selectors |
| 113 | /// out of. |
| 114 | /// |
| 115 | /// `into` arrives on a query string, so it is a reader's string by the time a |
| 116 | /// route hands it back here, and it lands in an `id` and inside a `#`-selector |
| 117 | /// in `hx-target`. Both are escaped by the emitter, so this is not what stops |
| 118 | /// an injection; what it stops is a selector that is escaped, well-formed and |
| 119 | /// means something other than the element it names. The same gate quasi's own |
| 120 | /// emitter puts in front of a handle it builds a program from. |
| 121 | /// |
| 122 | /// Every field name in the tree is already a plain handle. |
| 123 | |
| 124 | |
| 125 | !into.is_empty |
| 126 | && into.len <= 64 |
| 127 | && into |
| 128 | .bytes |
| 129 | .all |
| 130 | |
| 131 | |
| 132 | /// Where the picker comes from, and where a filtered grid comes from. |
| 133 | pub const PATH: &str = "/media/picker"; |
| 134 | |
| 135 | /// Where the grid alone comes from. |
| 136 | pub const GRID_PATH: &str = "/media/picker/grid"; |
| 137 | |
| 138 | /// What dismissing it calls. |
| 139 | pub const CLOSE_PATH: &str = "/media/picker/close"; |
| 140 | |
| 141 | /// The name the destination field travels under. |
| 142 | /// |
| 143 | /// Carried on every action the picker emits, because the picker is opened *for* |
| 144 | /// a box and the route rebuilds the cards against it. The shipped script held |
| 145 | /// the same fact in a module-level `targetTextareaId`. |
| 146 | pub const INTO: &str = "into"; |
| 147 | |
| 148 | /// The name the typed filter travels under. |
| 149 | /// |
| 150 | /// Prefixed, and both of these are, because a fragment's ids come from its |
| 151 | /// field names and this fragment lands inside a document somebody else built. |
| 152 | /// A picker carrying `id="name"` into a page with a name field on it is two |
| 153 | /// elements answering to one id, which is what `Filling::id_prefix` exists to |
| 154 | /// stop inside a form and has no equivalent for a whole document. |
| 155 | pub const NAME: &str = "media-name"; |
| 156 | |
| 157 | /// The name the chosen folder travels under. |
| 158 | pub const FOLDER: &str = "media-folder"; |
| 159 | |
| 160 | /// What the panel is called, for a stylesheet. |
| 161 | const PANEL: &str = "media-picker"; |
| 162 | |
| 163 | /// What the grid of tiles is called. |
| 164 | const GRID_WIDGET: &str = "media-picker-grid"; |
| 165 | |
| 166 | /// A named assembly, which is the hook a stylesheet attaches to. |
| 167 | /// |
| 168 | /// `RegionKind::Widget` rather than `Group` on all three, because that is what |
| 169 | /// the member is for: "the hook a stylesheet or a script attaches to in order to |
| 170 | /// draw the assembly the way a browser does it", and a name that is app |
| 171 | /// vocabulary rather than a class this renderer owns. The ids stay unique per |
| 172 | /// destination and are what an answer is aimed at; the widget name is what the |
| 173 | /// grid and the tiles are drawn by, and it is the same string on every picker in |
| 174 | /// the document. |
| 175 | |
| 176 | Widget |
| 177 | name: name.to_owned, |
| 178 | |
| 179 | |
| 180 | |
| 181 | /// How long the typed filter stands still before the grid is asked for. |
| 182 | /// |
| 183 | /// `discover_typeahead`'s number, and for its reason: it is the wait that makes |
| 184 | /// a typed filter one question per pause rather than one per keystroke. |
| 185 | const WAIT: Duration = from_millis; |
| 186 | |
| 187 | /// One media file, as the picker needs it. |
| 188 | /// |
| 189 | /// A view of `MediaFileResponse` rather than that type, so this module names |
| 190 | /// what it draws and nothing else: the picker has no use for an id, a size or a |
| 191 | /// created-at, and taking the whole response would tie the description to the |
| 192 | /// JSON shape of an API route it does not call. |
| 193 | |
| 194 | |
| 195 | /// The media file's own id, which is the tile's region id. |
| 196 | /// |
| 197 | /// Only here because a tile is two members and the vocabulary has no way to |
| 198 | /// hold two things together without a region, and a region has an id. See |
| 199 | /// the header on the gap that costs. |
| 200 | pub id: String, |
| 201 | /// What is read on the tile. |
| 202 | pub filename: String, |
| 203 | /// Which folder it is in, empty for the root. |
| 204 | pub folder: String, |
| 205 | /// What lands in the editor. Already a markdown image reference; this |
| 206 | /// module never spells one. |
| 207 | pub reference: String, |
| 208 | /// Where the thumbnail comes from. |
| 209 | pub url: String, |
| 210 | /// Whether there is a thumbnail to show at all. |
| 211 | pub image: bool, |
| 212 | |
| 213 | |
| 214 | /// The button that opens the picker, and the empty region it lands in. |
| 215 | /// |
| 216 | /// Both together because they are one arrangement: a trigger whose answer has |
| 217 | /// nowhere to go is a button that swaps a modal into itself. `into` is the |
| 218 | /// [`Field::name`] of the editor the picked reference goes to. |
| 219 | |
| 220 | |
| 221 | use Serves as _; |
| 222 | |
| 223 | let act = new |
| 224 | "Insert Image", |
| 225 | get |
| 226 | .carrying |
| 227 | .replacing, |
| 228 | ; |
| 229 | |
| 230 | format! |
| 231 | "{}{}" |
| 232 | new.fragment, |
| 233 | dismissed |
| 234 | ) |
| 235 | |
| 236 | |
| 237 | /// The picker itself: the filters, and the grid under them. |
| 238 | /// |
| 239 | /// `folders` is every folder the reader has, `name` and `folder` are the |
| 240 | /// filters as they currently stand, and `entries` is what those filters |
| 241 | /// already selected — the narrowing is the route's, not this module's, because |
| 242 | /// a filter the server applied is one query rather than a library sent to a |
| 243 | /// browser to hide most of. |
| 244 | |
| 245 | |
| 246 | entries: &[Entry], |
| 247 | folders: &[String], |
| 248 | into: &str, |
| 249 | name: &str, |
| 250 | folder: &str, |
| 251 | |
| 252 | use Serves as _; |
| 253 | |
| 254 | let mut panel = new |
| 255 | .with |
| 256 | .with |
| 257 | .with |
| 258 | folders, into, name, folder, |
| 259 | |
| 260 | .with; |
| 261 | |
| 262 | // Last, so the way out is the last thing a reader tabs to rather than the |
| 263 | // first. It is an ordinary act calling an ordinary route: the shipped |
| 264 | // dismissal was `display:none` plus a scrim click, and neither is |
| 265 | // describable — a region that is there and not shown is not a state the |
| 266 | // vocabulary has, and a scrim is not a control. |
| 267 | panel = panel.with |
| 268 | "Close", |
| 269 | get |
| 270 | .carrying |
| 271 | .replacing, |
| 272 | ; |
| 273 | |
| 274 | let region = new.with; |
| 275 | |
| 276 | new.fragment |
| 277 | |
| 278 | |
| 279 | /// The grid alone, for a filter that moved. |
| 280 | |
| 281 | |
| 282 | use Serves as _; |
| 283 | |
| 284 | new.fragment |
| 285 | |
| 286 | |
| 287 | /// The empty region, for a dismissal and for the page that has not opened one. |
| 288 | /// |
| 289 | /// The same markup in both cases on purpose: "closed" and "never opened" are |
| 290 | /// one state, so there is no third thing for a reader to be in. |
| 291 | |
| 292 | |
| 293 | use Serves as _; |
| 294 | |
| 295 | new |
| 296 | .fragment |
| 297 | |
| 298 | |
| 299 | declare! |
| 300 | /// The tiles, or the sentence that says why there are none. |
| 301 | /// |
| 302 | /// Two different emptinesses read the same here, and deliberately: a library |
| 303 | /// with nothing in it and a filter that matched nothing both leave the |
| 304 | /// reader with the filters they can see and change. |
| 305 | shape grid_slot ; |
| 306 | |
| 307 | region grid_id as widget |
| 308 | text "No media files match. Upload images in your Media Library tab first." |
| 309 | when entries.is_empty; |
| 310 | |
| 311 | for entry in entries.iter |
| 312 | include card; |
| 313 | |
| 314 | |
| 315 | |
| 316 | |
| 317 | declare! |
| 318 | /// One tile: one control, showing the thumbnail and saying the file name. |
| 319 | /// |
| 320 | /// The label is the file name, which is the fact the shipped script lost to |
| 321 | /// a misspelled JSON key. The deposit is `markdown_ref` verbatim, which is |
| 322 | /// the fact that script wrapped twice. |
| 323 | /// |
| 324 | /// `local`, so the press makes no request. The old picker made none either; |
| 325 | /// what it did instead was 155 lines of DOM building. |
| 326 | /// |
| 327 | /// # This was two members and half a tile until quasi 0.69.0 |
| 328 | /// |
| 329 | /// The conversion found that an [`Act`] was a label and nothing said a |
| 330 | /// control reads as a picture, so the tile was a region holding the picture |
| 331 | /// and the control as siblings and only the file name answered a press -- a |
| 332 | /// reader aiming at the thumbnail, which is the whole affordance of a |
| 333 | /// picture picker, hit nothing. `Act::shows` (quasicoherent `db998898`) is |
| 334 | /// that member, and the tile is one control again. |
| 335 | /// |
| 336 | /// The region went with it, and so did the id it needed. [`Entry::id`] is |
| 337 | /// kept for the caller's convenience and no longer addresses anything. |
| 338 | shape card ; |
| 339 | |
| 340 | act &entry.filename to local |
| 341 | filling into &entry.reference; |
| 342 | // The alt is the file name, because that is what the tile is for: a |
| 343 | // reader who cannot see the thumbnail is choosing between file names, |
| 344 | // which is exactly what the control says. The fit is the letterbox the |
| 345 | // shipped tile did with `object-fit: contain`: a thumbnail grid whose |
| 346 | // files are not all one shape, and the whole picture matters more than |
| 347 | // filling the square. |
| 348 | showing new.lazy.fit |
| 349 | when entry.image; |
| 350 | |
| 351 | |
| 352 | |
| 353 | declare! |
| 354 | /// The typed filter. |
| 355 | /// |
| 356 | /// It asks rather than writes: see this module's header on why a consult and |
| 357 | /// not `changes`. The folder rides along under [`Consult::sending`], so |
| 358 | /// narrowing by name inside a folder stays inside it. |
| 359 | /// |
| 360 | /// The folder is also carried on the action, which looks redundant and is |
| 361 | /// the fallback: htmx drops an address parameter whose name the payload |
| 362 | /// repeats, so the live select wins whenever it is found, and the carried |
| 363 | /// value is what a document with no select in reach sends instead. |
| 364 | /// |
| 365 | /// `sending` names a field document-wide, which is the vocabulary's own |
| 366 | /// reading. A document holding two open pickers would therefore match two |
| 367 | /// selects and send both values. Not guarded, because a picker is a modal |
| 368 | /// covering the viewport and a reader cannot reach the second trigger while |
| 369 | /// the first is open; recorded so the next reader knows it was seen rather |
| 370 | /// than missed. |
| 371 | shape name_filter ; |
| 372 | |
| 373 | field Text NAME "Filter by name" |
| 374 | consulting new |
| 375 | get |
| 376 | .carrying |
| 377 | .carrying |
| 378 | .replacing, |
| 379 | |
| 380 | .after |
| 381 | .sending; |
| 382 | placeholder "Filter by name..."; |
| 383 | value name; |
| 384 | |
| 385 | |
| 386 | |
| 387 | declare! |
| 388 | /// The folder chooser. |
| 389 | /// |
| 390 | /// A consult and not a write: it narrows what the grid shows and puts |
| 391 | /// nothing anywhere. `Consult::at_once` because a select is at its next |
| 392 | /// value or its last one and there is nothing to wait out -- which is the |
| 393 | /// half `aeb44860` left with the description after moving the event to the |
| 394 | /// renderer. It carries the typed filter forward on the action, because a |
| 395 | /// field's question sends its own value and nothing else. |
| 396 | shape folder_filter ; |
| 397 | |
| 398 | field Select FOLDER "Folder" |
| 399 | options folder_choices; |
| 400 | consulting at_once |
| 401 | get |
| 402 | .carrying |
| 403 | .carrying |
| 404 | .replacing, |
| 405 | ; |
| 406 | value folder; |
| 407 | |
| 408 | |
| 409 | |
| 410 | /// Every folder the reader has, under an "All folders" that clears the filter. |
| 411 | /// |
| 412 | /// A supplier because a list built from an iterator is what the deferred table |
| 413 | /// names one for. The root folder is the empty string and reads as `(root)`, |
| 414 | /// which is the one place a folder's value and its label differ. |
| 415 | |
| 416 | let mut options = vec!; |
| 417 | options.extend |
| 418 | folders |
| 419 | .iter |
| 420 | .map, |
| 421 | ; |
| 422 | options |
| 423 | |
| 424 | |
| 425 | |
| 426 | |
| 427 | use *; |
| 428 | |
| 429 | |
| 430 | let reference = if folder.is_empty |
| 431 | format! |
| 432 | else |
| 433 | format! |
| 434 | ; |
| 435 | Entry |
| 436 | id: "11111111-1111-1111-1111-111111111111".to_owned, |
| 437 | filename: filename.to_owned, |
| 438 | folder: folder.to_owned, |
| 439 | reference, |
| 440 | url: format!, |
| 441 | image: true, |
| 442 | |
| 443 | |
| 444 | |
| 445 | /// The whole point of the conversion, and the member it waited eleven days |
| 446 | /// for: a card names the box it writes into and what lands there. |
| 447 | |
| 448 | |
| 449 | let html = grid; |
| 450 | |
| 451 | assert!; |
| 452 | assert! |
| 453 | html.contains, |
| 454 | "{html}" |
| 455 | ; |
| 456 | // Local, so the press is not a request. The shipped picker made none |
| 457 | // either; this says so in the description rather than by omission. |
| 458 | assert!; |
| 459 | assert!; |
| 460 | |
| 461 | |
| 462 | /// The tile is two members and the picture is one of them. Half a tile is |
| 463 | /// pressable, which is the gap this conversion found: an `Act` is a label, |
| 464 | /// so nothing says a control reads as a picture. |
| 465 | |
| 466 | |
| 467 | let html = grid; |
| 468 | |
| 469 | assert! |
| 470 | html.contains, |
| 471 | "{html}" |
| 472 | ; |
| 473 | // The alt is the file name, because choosing without the thumbnail is |
| 474 | // choosing between names, which is what the control below says too. |
| 475 | assert!; |
| 476 | assert!; |
| 477 | // The fit is in the description, and it is what lets `style.css` cap |
| 478 | // the tile's height with `max-height` alone: the design system already |
| 479 | // owns this picture's width, height and background, and an app rule |
| 480 | // taking one of those is what `makeover-build`'s drift check refuses. |
| 481 | assert!; |
| 482 | // And the tile is the control, since quasi 0.69.0: the picture is |
| 483 | // inside the element that answers the press, so aiming at the thumbnail |
| 484 | // picks the file. It was a `media-card-<id>` region holding the picture |
| 485 | // and the button as siblings, and only the button answered. |
| 486 | assert! |
| 487 | !html.contains, |
| 488 | "the per-tile region is gone: {html}" |
| 489 | ; |
| 490 | let control = html |
| 491 | .split_once |
| 492 | .and_then |
| 493 | .map_or; |
| 494 | assert!; |
| 495 | assert!; |
| 496 | |
| 497 | |
| 498 | /// A video has no thumbnail to show, so it is the control alone rather than |
| 499 | /// a broken image. |
| 500 | |
| 501 | |
| 502 | let mut clip = entry; |
| 503 | clip.image = false; |
| 504 | let html = grid; |
| 505 | |
| 506 | assert!; |
| 507 | assert!; |
| 508 | |
| 509 | |
| 510 | /// Defect 1 of the three in the header. `media-picker.js` reads |
| 511 | /// `f.file_name` and the JSON says `filename`, so every tile shipped |
| 512 | /// unlabelled. |
| 513 | |
| 514 | |
| 515 | let html = grid; |
| 516 | assert!; |
| 517 | |
| 518 | |
| 519 | /// Defect 3. `markdown_ref` already carries the wrapper, so what the |
| 520 | /// shipped button inserted was `)`. |
| 521 | |
| 522 | |
| 523 | let html = grid; |
| 524 | assert!; |
| 525 | |
| 526 | |
| 527 | /// A file name is a reader's string on its way into an attribute, which is |
| 528 | /// the one thing the shipped script got right and got right the hard way, |
| 529 | /// with `textContent` and `dataset`. Here it is the emitter's ordinary |
| 530 | /// escaping, in both places a name lands. |
| 531 | |
| 532 | |
| 533 | let html = grid; |
| 534 | |
| 535 | assert!; |
| 536 | assert!; |
| 537 | |
| 538 | |
| 539 | /// The filters ask for the grid and not for the modal, so the box being |
| 540 | /// typed into survives its own answer. |
| 541 | |
| 542 | |
| 543 | let html = picker |
| 544 | &, |
| 545 | &, |
| 546 | "body", |
| 547 | "", |
| 548 | "", |
| 549 | ; |
| 550 | |
| 551 | assert! |
| 552 | html.contains, |
| 553 | "{html}" |
| 554 | ; |
| 555 | assert!; |
| 556 | // And the wait, which is what makes a typed filter one question per |
| 557 | // pause rather than one per keystroke against Postgres. |
| 558 | assert!; |
| 559 | |
| 560 | |
| 561 | /// The folder chooser's options, which are a supplier's list now rather |
| 562 | /// than an argument to `Field::select`. The root folder reads as `(root)` |
| 563 | /// and is the one place a folder's value and its label differ. |
| 564 | |
| 565 | |
| 566 | let html = picker |
| 567 | &, |
| 568 | &, |
| 569 | "body", |
| 570 | "", |
| 571 | "drums", |
| 572 | ; |
| 573 | |
| 574 | assert!; |
| 575 | assert!; |
| 576 | assert!; |
| 577 | |
| 578 | |
| 579 | /// The picker is opened for a box, so every action it emits carries which |
| 580 | /// one. The shipped script held this in a module-level variable. |
| 581 | |
| 582 | |
| 583 | let html = picker; |
| 584 | assert!; |
| 585 | assert!; |
| 586 | |
| 587 | |
| 588 | /// A modal, said in a word every renderer reads rather than in a fixed |
| 589 | /// position and a scrim. |
| 590 | |
| 591 | |
| 592 | let html = picker; |
| 593 | assert!; |
| 594 | assert!; |
| 595 | |
| 596 | |
| 597 | /// Closed and never-opened are one state, so a page holds the same empty |
| 598 | /// region before the first press and after the last dismissal. |
| 599 | |
| 600 | |
| 601 | let opened = trigger; |
| 602 | assert!; |
| 603 | assert! |
| 604 | dismissed.contains, |
| 605 | "{}", |
| 606 | dismissed |
| 607 | ; |
| 608 | assert!; |
| 609 | |
| 610 | |
| 611 | /// The trigger opens the picker into the region beside it, rather than |
| 612 | /// swapping a modal into the button that asked for it. |
| 613 | |
| 614 | |
| 615 | let html = trigger; |
| 616 | assert! |
| 617 | html.contains, |
| 618 | "{html}" |
| 619 | ; |
| 620 | assert! |
| 621 | html.contains, |
| 622 | "{html}" |
| 623 | ; |
| 624 | |
| 625 | |
| 626 | /// An empty library and an empty filter result read the same, and neither |
| 627 | /// is a blank box. |
| 628 | |
| 629 | |
| 630 | let html = grid; |
| 631 | assert!; |
| 632 | |
| 633 | |
| 634 |