softr-vibe-coding 2.9.0 → 2.10.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 +8 -0
- package/README.md +12 -2
- package/SKILL.md +11 -0
- package/package.json +1 -1
- package/references/anti-patterns.md +8 -0
- package/references/common-patterns.md +1 -1
- package/references/printing.md +533 -0
- package/references/searchable-dropdown.md +188 -13
- package/references/softr-mcp.md +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to this skill are documented here. Versions follow [Semantic
|
|
|
4
4
|
|
|
5
5
|
Entries from 1.3.1 onward are generated automatically from git commit subjects between version bumps (see `.github/workflows/publish.yml`). Entries before 1.3.1 were backfilled by hand from the existing commit history.
|
|
6
6
|
|
|
7
|
+
## [2.10.0] - 2026-09-30
|
|
8
|
+
- Add the print-in-a-new-window rule and references/printing.md (verified live 2026-09-30)
|
|
9
|
+
- Add right-edge placement and the schema-less workspace-tool correction (2026-09-30)
|
|
10
|
+
|
|
11
|
+
## [2.9.1] - 2026-09-30
|
|
12
|
+
- Add right-edge placement and the schema-less workspace-tool correction (2026-09-30)
|
|
13
|
+
- Document dropdown clipping by overflow ancestors (verified live 2026-09-30)
|
|
14
|
+
|
|
7
15
|
## [2.9.0] - 2026-09-29
|
|
8
16
|
- Add runtime facts verified live 2026-09-18
|
|
9
17
|
|
package/README.md
CHANGED
|
@@ -169,7 +169,7 @@ Create a contact form that creates records in our Airtable Contacts table
|
|
|
169
169
|
softr-vibe-coding/
|
|
170
170
|
├── SKILL.md # Main skill
|
|
171
171
|
│ # Workflow, code structure, visual baseline,
|
|
172
|
-
│ # components, settings,
|
|
172
|
+
│ # components, settings, 28 hard constraints
|
|
173
173
|
│
|
|
174
174
|
├── ui-ux-guidelines.md # Design reference
|
|
175
175
|
│ # 26 sections: hierarchy, color, typography,
|
|
@@ -223,6 +223,13 @@ softr-vibe-coding/
|
|
|
223
223
|
│ │ # MCP/CLI install (@latest npx + browser step),
|
|
224
224
|
│ │ # extract → poll → findings → generate → write
|
|
225
225
|
│ │ # flow, DESIGN.md anatomy, drift QA
|
|
226
|
+
│ ├── printing.md # Printing from a block: ALWAYS a new window
|
|
227
|
+
│ │ # with its own document (never window.print()
|
|
228
|
+
│ │ # on the page, never an in-page print view) —
|
|
229
|
+
│ │ # escaped HTML builder, pop-up-safe open from
|
|
230
|
+
│ │ # the click, print once stylesheets, fonts and
|
|
231
|
+
│ │ # images load, ?print=1 deep link, paper layout
|
|
232
|
+
│ │ # (Sep 30 2026)
|
|
226
233
|
│ ├── quick-reference.md # Syntax cheat sheet
|
|
227
234
|
│ │ # Imports, hook signatures, mutation shapes,
|
|
228
235
|
│ │ # field mapping, component skeleton
|
|
@@ -232,7 +239,10 @@ softr-vibe-coding/
|
|
|
232
239
|
│ # click-outside, A-Z inside the component,
|
|
233
240
|
│ # multi-token filter, searchable BY DEFAULT
|
|
234
241
|
│ # (bare = click-only; searchable={false} only
|
|
235
|
-
│ # for a fixed enum being set — Sep 10 2026)
|
|
242
|
+
│ # for a fixed enum being set — Sep 10 2026),
|
|
243
|
+
│ # overflow-clipping ancestors: never clip a cell
|
|
244
|
+
│ # holding a Combo, clip-aware drop-up + list
|
|
245
|
+
│ # height, list-only scrolling (Sep 30 2026)
|
|
236
246
|
│
|
|
237
247
|
├── tools/ # Bundled CLI scripts (run, not read)
|
|
238
248
|
│ ├── get-airtable-base # Full Airtable base schema export (bash + jq)
|
package/SKILL.md
CHANGED
|
@@ -90,6 +90,8 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
|
|
|
90
90
|
- Sequential multi-row saves use `await hook.mutateAsync(...)` per row, in order, with stop-on-failure + retry state — `mutateAsync` is fully supported on the current platform (verified 2026-08-25; the old ".mutate() only" Action-parser rule is gone — see [datasources/writing.md](datasources/writing.md)). Independent writes to **different tables** may run in parallel via `Promise.all`; drag/reassign UIs should be optimistic with an Undo toast — see [writing.md → Parallel writes across tables](datasources/writing.md#parallel-writes-across-tables-the-one-sanctioned-parallelism)
|
|
91
91
|
- No hardcoded domains in links -- use relative paths (`/page?recordId=...`); same-page anchors written relative too (`/#section`)
|
|
92
92
|
- **No `<select>` and no shadcn `<Select>`** — both break inside a block's shadow DOM (native hands the list to the OS; shadcn portals outside the shadow root and arrives unstyled). Use the `Combo` pattern in [references/searchable-dropdown.md](references/searchable-dropdown.md) — **searchable by default** for every framed filter or form field whatever the option count; `bare` inline editors are click-only; `searchable={false}` only on a short fixed enum the user is setting (a status, a location, a group-by)
|
|
93
|
+
- No clipping class (`overflow-hidden`, `overflow-*-auto`, `truncate`, `line-clamp-*`) on any element that contains a `Combo` — its panel is absolutely positioned in local DOM, so a clipping `<td>` cuts the menu to the row's height; bound an over-wide chip at the chip (`min-w-0 truncate`), and never `scrollIntoView` inside the panel. See [references/searchable-dropdown.md](references/searchable-dropdown.md#the-four-things-that-will-bite-you), item 4
|
|
94
|
+
- Any **Print** control opens a **new window with its own document** — `window.open` straight from the click, an escaped standalone HTML printout written into it, `print()` once its stylesheets, fonts and images are in, the button disabled until the data has fully loaded. No `window.print()` on the Softr page, no in-page print view (Hard Constraint 28). See [references/printing.md](references/printing.md)
|
|
93
95
|
- Static block: no hardcoded user-visible copy — every string/image/link is an editable setting (see [references/editable-settings.md](references/editable-settings.md#granularity-doctrine-settings-first-static-blocks))
|
|
94
96
|
- Array-setting rows keyed by **index**, never by a builder-editable field value
|
|
95
97
|
- Media settings that may start empty (`src: ""`) gated with a conditional render or placeholder — never an unconditional `<img src={setting.src}>`
|
|
@@ -174,6 +176,7 @@ For advanced patterns beyond data fetching, load the relevant reference when the
|
|
|
174
176
|
| Debugging a broken block, checking patterns before delivery, full violation catalog | [references/anti-patterns.md](references/anti-patterns.md) |
|
|
175
177
|
| Quick syntax check — import paths, hook signatures, mutation call shapes, field mapping | [references/quick-reference.md](references/quick-reference.md) |
|
|
176
178
|
| Any **dropdown / picker / combobox** in a block — why shadcn `<Select>` and native `<select>` both fail inside the shadow DOM, the `composedPath()` click-outside, sorting A→Z inside the component, multi-token filtering, **searchable by default** regardless of option count (`bare` inline editors click-only; `searchable={false}` only for a short fixed enum being set), the `bare` inline-editor variant | [references/searchable-dropdown.md](references/searchable-dropdown.md) |
|
|
179
|
+
| **Printing** anything from a block — always a new window/tab holding its own document, never `window.print()` on the page or an in-page print view: the escaped HTML builder, pop-up-safe opening from the click, print-when-ready (stylesheets, fonts and images, capped), Print disabled until the data has loaded, the `?print=1` deep link from another page, paper layout (shared `<colgroup>`, `vertical-align: middle`, tick boxes) | [references/printing.md](references/printing.md) |
|
|
177
180
|
| Small reusable patterns — `localStorage` cross-page state, clipboard copy button | [references/common-patterns.md](references/common-patterns.md) |
|
|
178
181
|
| Writing Airtable Automation Scripts / Scripting Extension scripts / Airtable formulas — companion to Softr blocks for cross-table cascades and computed values | [references/airtable-automations.md](references/airtable-automations.md) |
|
|
179
182
|
| The official **Softr MCP server** — Softr DB schema + full record/table/field/database CRUD (deletes included), field-level browsing of connected Airtable / Google Sheets / Notion / Supabase integrations, **creating, editing, versioning, and deploying Vibe Coding blocks directly** (`get_vibe_coding_docs`, `create_vibe_coding_block`, ...), app management/scaffolding, the **Softr Workflows** suite (26 tools, 418-node catalog), and **per-application MCP servers** | [references/softr-mcp.md](references/softr-mcp.md) |
|
|
@@ -603,6 +606,14 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
|
|
|
603
606
|
one table merge into ONE UPDATE_RECORD action (field list = the union), filed under the table's
|
|
604
607
|
FIRST connection even when a hook points at a second one. Point writes at the first connection.
|
|
605
608
|
Verified live 2026-09-18. See [datasources/writing.md](datasources/writing.md#actions-register-per-table-not-per-hook-or-connection).
|
|
609
|
+
28. **Print in a new window, never on the page [house]** -- A Print control opens a new window
|
|
610
|
+
(`window.open("", "_blank", …)`, synchronously in the click handler; a toast if it returns
|
|
611
|
+
`null`) and writes a standalone, escaped HTML printout into it, printed once its stylesheets,
|
|
612
|
+
fonts and images are in. Never `window.print()` on the Softr page: a block is page content in
|
|
613
|
+
a shadow root, so the page prints Softr's header, footer and every sibling block, and hiding
|
|
614
|
+
them takes global CSS across Softr's page structure as well as print CSS in the block. Never
|
|
615
|
+
an in-page "print view" either (Leo rejected it by name). Verified live 2026-09-30. See
|
|
616
|
+
[references/printing.md](references/printing.md).
|
|
606
617
|
|
|
607
618
|
## Style Conventions
|
|
608
619
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "softr-vibe-coding",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "Claude Code skill for generating production-ready Softr Vibe Coding blocks (JSX). Installs into ~/.claude/skills/ and auto-updates on each Claude Code session.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"softr-vibe-coding": "./bin/cli.js"
|
|
@@ -74,6 +74,8 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
74
74
|
| Placing a `<NavigationAction navigation={{ action: "OPEN_CHAT" }}>` Ask-AI button on a block that has no data source connected | Connect the block to the data source the AI should read from in Studio's Source tab. Softr's AI pulls context from the **block that triggered the chat**, not from the page — a button-only helper block with no data source causes `chat/prepare` → HTTP 500 ("Failed to prepare AI assistant") even though the chat UI opens fine. The block doesn't need to read or write records itself; the connection is purely for AI context. Verified by direct experiment, May 2026 |
|
|
75
75
|
| Emojis in UI | lucide-react icons only |
|
|
76
76
|
| `document.elementFromPoint(x, y)` to hit-test during a drag or custom pointer interaction | It returns the block's shadow **host**, not the element under the cursor — same boundary that stops `getElementById` and URL-fragment lookup. Either call it on the shadow root (`ref.current.getRootNode().elementFromPoint(x, y)`) or, better, keep refs to the candidate elements and compare `getBoundingClientRect()` yourself: rects need no shadow-root plumbing and work identically when the block is later reused elsewhere |
|
|
77
|
+
| `overflow-hidden`, `truncate`, `line-clamp-*` or `overflow-*-auto` on a container that holds a dropdown or popover — typically a `<td>` clipped so an over-wide status chip stops at its own column | **Symptom:** the menu opens cut to the height of its row or its scroller: one or two options showing, the rest unreachable by mouse. **Cause:** the `Combo` panel is `position: absolute` in the block's own DOM (a portal would leave the shadow root and lose its styles), and an absolutely positioned box is clipped by every ancestor whose `overflow` is not `visible`. `truncate` and `line-clamp-*` set `overflow: hidden`; `overflow-x-auto` turns `overflow-y` to `auto` as well. **Fix:** no clipping class between the Combo and the scroller it belongs to; bound the chip at the chip (`min-w-0 truncate` on the chip inside the flex trigger); measure the drop-up and the list height against the clipping ancestors, not the window. Hit in production 2026-09-30: three ROSIE item tables clipped the status cell as a 2px backstop, next to a comment claiming the menu was portaled — it had stopped being portaled when the tables moved from shadcn `<Select>` to `Combo`. See [searchable-dropdown.md](searchable-dropdown.md#the-four-things-that-will-bite-you), item 4 |
|
|
78
|
+
| `el.scrollIntoView({ block: "nearest" })` to keep a dropdown's highlighted option in view (or a plain `focus()` on its search box) | **Symptom:** the table or the page jumps when a menu opens near an edge; inside a clipped cell the trigger itself scrolls out of view. **Cause:** `scrollIntoView` scrolls EVERY scrollable ancestor until the element shows, and `overflow: hidden` boxes are still scrollable from script; `focus()` scrolls ancestors the same way. **Fix:** scroll the list element only — compare the option's rect with the list's and adjust `list.scrollTop` — and focus with `{ preventScroll: true }`. Verified in Chromium 2026-09-30: `scrollIntoView` scrolled an `overflow: hidden` cell by 164px, the list-only scroll moved nothing outside the list. See [searchable-dropdown.md](searchable-dropdown.md#the-four-things-that-will-bite-you), item 4, rule 3 |
|
|
77
79
|
| Positioning repeated page chrome (back button, title, primary action) per-block, without checking the pages that already have it | Chrome the user meets on more than one screen is a cross-page contract. Copy the exact offset from the blocks that already ship it, and change every page in one edit. Let the wrapper's padding be the only thing positioning it — `mb-4` and NO top margin on a back button — so one number per page governs it. Verified 2026-09-09: an extra `mt-6` sat one detail page's back button 24px lower than another's, and **each block looked correct in isolation**. See SKILL.md's Block Placement section |
|
|
78
80
|
| A loading skeleton carrying a different border / offset from the component it stands in for | The skeleton must track the component's REST state (border colour, padding, chrome offsets), never its hover state. If they disagree the layout visibly re-draws the instant data lands — the exact thing a skeleton exists to prevent. Verified 2026-09-09 twice in one session: a card grid re-outlined itself on load, and a back button jumped 24px. See [ui-ux-guidelines.md](../ui-ux-guidelines.md) §12 |
|
|
79
81
|
| `focus-visible:` on a card or row that only *contains* buttons | The container is a plain `<div>` and never takes focus, so it is dead CSS. Use `focus-within:` on the container (pairs with its `hover:` treatment) and keep `focus-visible:ring-2` on the button/link itself |
|
|
@@ -89,6 +91,12 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
89
91
|
| Softr nav dropdown panel shows a tall blank gap below the items, and `height: auto` won't shrink it | The items sit in a CSS grid Softr sets to `grid-auto-flow: column` with pre-sized empty row tracks (`grid-template-rows: 60px 60px…`). Override the flow on `.softr-topbar [role="menu"] [role="group"]`: `grid-auto-flow: row !important; grid-template-rows: none !important; grid-auto-rows: auto !important` (leave `grid-template-columns` to preserve the menu width). Verified June 2026. See [native-chrome-styling.md](native-chrome-styling.md). |
|
|
90
92
|
| Setting the page background on `body` (or any single element) — it appears to do nothing | Softr paints the SAME page fill on `html`, `body`, `#page-content`, AND a deeper class-less wrapper div, stacked — so styling one gets covered. Paint your backdrop on `html`, then clear the duplicates above it: `body`, `#page-content`, and `#page-content div` — but EXCLUDE the header subtree with `:not(.softr-topbar):not(.softr-topbar *)` (it renders inside `#page-content`, and `#page-content`'s id specificity would otherwise flatten the dropdown panel). Verified June 2026. See [native-chrome-styling.md](native-chrome-styling.md). |
|
|
91
93
|
|
|
94
|
+
## Printing
|
|
95
|
+
|
|
96
|
+
| Anti-Pattern | Correct Approach |
|
|
97
|
+
|---|---|
|
|
98
|
+
| Printing the Softr page — `window.print()` from a block, with or without `@media print` CSS to hide the rest — or an in-page "print view": the block switches itself to a print layout under the app chrome, with "Print again" / "Exit print view" buttons | **Symptom:** the paper carries Softr's header and footer, every sibling block and the block's own controls; or the user lands in a second screen of the block that they have to find their way out of — "a weird UI", which Leo rejected by name on 2026-09-30. **Cause:** a block is page content in a shadow root. The app chrome and the sibling blocks are outside it, so the block's own print CSS cannot hide them; that takes global Custom Code CSS aimed at Softr's page structure, which you do not control. A print view changes nothing about that: it still ends in `window.print()` on the same page. **Fix:** Print opens a new window holding its own document — `window.open("", "_blank", …)` synchronously in the click (a toast if it returns `null`), a standalone, escaped HTML printout written into it, `print()` once its stylesheets, fonts and images are in. From another page, the linking page opens the target with `?print=1` in a window of its own, and the target block replaces its own document with the printout. Verified live 2026-09-30. See [printing.md](printing.md) |
|
|
99
|
+
|
|
92
100
|
## Permissions
|
|
93
101
|
|
|
94
102
|
| Anti-Pattern | Correct Approach |
|
|
@@ -515,4 +515,4 @@ function onKeyDown(e) {
|
|
|
515
515
|
|
|
516
516
|
Make the rows themselves focusable too (`tabIndex={0}`, an `onKeyDown` that opens on Enter only when `event.target === event.currentTarget`, so an Enter on the anchor inside the row is not handled twice), and let `onMouseEnter` *and* `onFocus` both move the highlight onto the row — the highlight is the single answer to "which record does Enter open", whichever device last touched it. That is the shape `projects-table.jsx` shipped on 2026-09-10.
|
|
517
517
|
|
|
518
|
-
Paint the highlighted row with the same colour the mouse hover gets (`data-active="true"` + `bg-[#FFF7EF]`) and scroll it into view when it moves (`querySelector('[data-active="true"]').scrollIntoView({ block: "nearest" })` in a `useEffect` on `activeIdx`)
|
|
518
|
+
Paint the highlighted row with the same colour the mouse hover gets (`data-active="true"` + `bg-[#FFF7EF]`) and scroll it into view when it moves (`querySelector('[data-active="true"]').scrollIntoView({ block: "nearest" })` in a `useEffect` on `activeIdx`). That is right here, because these rows are page content and the table's scroller and the page *should* move to them. Inside a dropdown it is wrong, and the Combo scrolls only its own list — see [searchable-dropdown.md](searchable-dropdown.md#the-four-things-that-will-bite-you), item 4, rule 3. Reset `active` to 0 whenever the query changes: the old index points at a row that may no longer be in the list. `autoFocus` is right only when the block *is* the page's reason to exist — an index page whose first act is always a search; on a page with content above the table, a focus steal scrolls the page to the box.
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
# Printing from a block — a window of its own
|
|
2
|
+
|
|
3
|
+
**Print opens a new window (or tab) holding its own printout document. Always.** Never
|
|
4
|
+
`window.print()` on the Softr page, and never an in-page "print view". This is the default for
|
|
5
|
+
every Print, "printable version" or save-as-PDF control in a block, whatever the data source.
|
|
6
|
+
Leo, 2026-09-30: "I don't want this print view, it needs a new tab opening", and as the default,
|
|
7
|
+
"so it doesn't create a weird UI".
|
|
8
|
+
|
|
9
|
+
A block is page *content*, rendered inside a shadow root, and that rules out both of the obvious
|
|
10
|
+
ways to print:
|
|
11
|
+
|
|
12
|
+
| Option | What ends up on paper |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `window.print()` from the block | The whole Softr page: the app header, the footer, every sibling block, and the block's own buttons and filters. The block's CSS lives in its shadow root and cannot reach any of the rest. Hiding it takes global Custom Code CSS aimed at Softr's page structure, which you do not control ([native-chrome-styling.md](native-chrome-styling.md)), plus print CSS inside the block for its own controls: two stylesheets in two places. |
|
|
15
|
+
| An in-page "print view" (the block switches itself to a print layout) | A second screen of the block, still under the app chrome, with its own "Print again" / "Exit print view" buttons: a mode the user has to find their way out of. It still prints through `window.print()`, so it inherits the whole row above. Leo rejected it by name. |
|
|
16
|
+
| A new window holding its own document (this page) | The printout and nothing else. No chrome to hide, no print CSS, no siblings, and the same from any page. The one cost is a pop-up, and the click itself gets it past the blocker. |
|
|
17
|
+
|
|
18
|
+
Four parts make it work: a **builder** that turns the view into one HTML string (1), a **click**
|
|
19
|
+
that opens the window and writes the string into it (2), a **wait** so it prints only once fonts
|
|
20
|
+
and images are in (3), and a **Print button that waits for the data** (4). Printing from *another*
|
|
21
|
+
page adds the `?print=1` deep link (5). Then the paper layout (6), and gotchas and testing (7).
|
|
22
|
+
|
|
23
|
+
**Verified live 2026-09-30** in a Softr preview: the button opens a separate window holding only
|
|
24
|
+
the printout (no app header or footer, no controls), photos load before print fires, and the page
|
|
25
|
+
behind it is untouched; a `?print=1` link opens a window that shows "Preparing the printout…" and
|
|
26
|
+
then becomes the same printout; every table cell measured 0px off its row's middle. (The snippets
|
|
27
|
+
are modern TS, per SKILL.md's Style Conventions, generalised from the var-style original that was
|
|
28
|
+
verified.)
|
|
29
|
+
|
|
30
|
+
## 1. The builder: one HTML string
|
|
31
|
+
|
|
32
|
+
The printout is a complete HTML document, built as one string by module-scope functions. Three
|
|
33
|
+
rules make it safe and self-sufficient.
|
|
34
|
+
|
|
35
|
+
**Escape every interpolated value, text and attribute values alike.** The window that
|
|
36
|
+
`window.open("")` returns is an `about:blank` page on the app's own origin, and a `?print=1`
|
|
37
|
+
printout (section 5) *is* the app's page. Record text written in unescaped is markup: an
|
|
38
|
+
`<img onerror=…>` in a vendor name runs as the signed-in user. Escape all five characters, `&`
|
|
39
|
+
first. The builder's attributes are single-quoted (`src='…'`), so `'` is not optional:
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
function escapeHtml(value: unknown): string {
|
|
43
|
+
return String(value ?? "")
|
|
44
|
+
.replace(/&/g, "&") // first, or it re-escapes the four below
|
|
45
|
+
.replace(/</g, "<")
|
|
46
|
+
.replace(/>/g, ">")
|
|
47
|
+
.replace(/"/g, """)
|
|
48
|
+
.replace(/'/g, "'");
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Then every `${…}` in the builder is one of three things: `escapeHtml(…)`, a number the code
|
|
53
|
+
computed, or a fragment built only from those two and literals (`printCellHtml(…)`, `colgroup`,
|
|
54
|
+
`headRow`, `PRINT_STYLES`). Nothing else.
|
|
55
|
+
|
|
56
|
+
**Bring your own styles and fonts.** The new document starts empty. Tailwind, shadcn, the lucide
|
|
57
|
+
React icons, the app's fonts and the Custom Code header CSS all stay behind in the app, so a class
|
|
58
|
+
like `text-sm` means nothing there. Write plain CSS into the printout's own `<style>`, load the
|
|
59
|
+
brand font with its own `<link>` (DESIGN.md's Font URLs), and draw anything iconic as text or an
|
|
60
|
+
inline SVG string.
|
|
61
|
+
|
|
62
|
+
**The `<title>` is the PDF's file name.** "Save as PDF" names the file after it, so give it the
|
|
63
|
+
context as well as the view: `Project name — List name`, not `Print`.
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
/* One cell on paper: the label the row shows ON SCREEN for that column (formatted dates, "—" for
|
|
67
|
+
empty), as escaped HTML. The name cell carries the photo at 24px, or an empty square of the
|
|
68
|
+
same size where there is none, so the names keep one straight edge. Adapt to your row shape. */
|
|
69
|
+
type PrintRow = { name: string; photoUrl: string; labels: Record<string, string> };
|
|
70
|
+
|
|
71
|
+
function printCellHtml(row: PrintRow, key: string): string {
|
|
72
|
+
if (key === "name") {
|
|
73
|
+
const photo = row.photoUrl
|
|
74
|
+
? `<img class='ph' src='${escapeHtml(row.photoUrl)}' alt=''>`
|
|
75
|
+
: "<span class='ph none'></span>";
|
|
76
|
+
return `<span class='item'>${photo}<span>${escapeHtml(row.name)}</span></span>`;
|
|
77
|
+
}
|
|
78
|
+
return escapeHtml(row.labels[key] || "—");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* Plain CSS for a document with no Tailwind in it. Black text on white prints best; put the
|
|
82
|
+
brand colour (DESIGN.md) on the eyebrow and the heading only. Section 6 explains each rule. */
|
|
83
|
+
const PRINT_STYLES = [
|
|
84
|
+
"body{font:12px Inter,Helvetica,Arial,sans-serif;color:#000;margin:24px}",
|
|
85
|
+
".eyebrow{font-size:13px;font-weight:600;letter-spacing:0.75px;text-transform:uppercase;color:#111;margin:0 0 4px}",
|
|
86
|
+
"h1{font-size:18px;font-weight:600;color:#111;margin:0 0 2px}",
|
|
87
|
+
".meta{font-size:11px;color:#333;margin:0 0 2px}",
|
|
88
|
+
".meta.note{font-weight:600}",
|
|
89
|
+
".grp{font-size:12px;font-weight:700;margin:14px 0 4px}",
|
|
90
|
+
"table{width:100%;table-layout:fixed;border-collapse:collapse;font-size:11px}",
|
|
91
|
+
"th{text-align:left;border-bottom:1px solid #DDD;padding:3px 4px;overflow-wrap:anywhere}",
|
|
92
|
+
"td{text-align:left;border-bottom:1px solid #DDD;padding:3px 4px;vertical-align:middle;overflow-wrap:anywhere}",
|
|
93
|
+
"tbody tr{break-inside:avoid}",
|
|
94
|
+
".item{display:flex;align-items:center;gap:6px}",
|
|
95
|
+
".ph{display:inline-block;flex:none;width:24px;height:24px;object-fit:cover;border:1px solid #DDD}",
|
|
96
|
+
".ph.none{background:#F2F2F2;-webkit-print-color-adjust:exact;print-color-adjust:exact}",
|
|
97
|
+
".chk{text-align:center}",
|
|
98
|
+
".box{display:block;margin:0 auto;width:12px;height:12px;border:1px solid #000}",
|
|
99
|
+
".notes{margin-top:18px;break-inside:avoid}",
|
|
100
|
+
".notes-body{white-space:pre-wrap;font-size:12px}",
|
|
101
|
+
"@page{margin:14mm}",
|
|
102
|
+
].join("");
|
|
103
|
+
|
|
104
|
+
type PrintColumn = { key: string; label: string };
|
|
105
|
+
type PrintContext = {
|
|
106
|
+
title: string; // the <title>: what "Save as PDF" names the file
|
|
107
|
+
eyebrow: string; // the context above the heading (the project, the client), or ""
|
|
108
|
+
heading: string;
|
|
109
|
+
meta: string[]; // plain-text lines under the heading, the print date first
|
|
110
|
+
filterNote: string; // "Filtered view — 12 of 40 items" while a search or filter is on, else ""
|
|
111
|
+
columns: PrintColumn[]; // the columns on show, in their on-screen order
|
|
112
|
+
widths: Record<string, number>; // on-screen widths by key; only the proportions matter
|
|
113
|
+
groups: { name: string; rows: PrintRow[] }[]; // one table per group; name "" when ungrouped
|
|
114
|
+
tickBox: boolean; // a title-less ☐ column at the end, to tick on paper
|
|
115
|
+
notes: string;
|
|
116
|
+
emptyText: string;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function buildPrintHtml(ctx: PrintContext): string {
|
|
120
|
+
const cols = ctx.columns;
|
|
121
|
+
const tick = ctx.tickBox ? 6 : 0; // the ☐ column's share of the width, in %
|
|
122
|
+
const total = cols.reduce((sum, c) => sum + (ctx.widths[c.key] || 100), 0);
|
|
123
|
+
const pct = (key: string) => (((ctx.widths[key] || 100) / total) * (100 - tick)).toFixed(2);
|
|
124
|
+
/* ONE <colgroup>, shared by every group's table. With table-layout:fixed, that is what lines
|
|
125
|
+
the columns up from one group to the next (section 6). */
|
|
126
|
+
const colgroup =
|
|
127
|
+
"<colgroup>" +
|
|
128
|
+
cols.map((c) => `<col style='width:${pct(c.key)}%'>`).join("") +
|
|
129
|
+
(tick ? `<col style='width:${tick}%'>` : "") +
|
|
130
|
+
"</colgroup>";
|
|
131
|
+
const headRow =
|
|
132
|
+
"<tr>" +
|
|
133
|
+
cols.map((c) => `<th>${escapeHtml(c.label)}</th>`).join("") +
|
|
134
|
+
(tick ? "<th aria-label='Done'></th>" : "") + // no title over the tick boxes
|
|
135
|
+
"</tr>";
|
|
136
|
+
|
|
137
|
+
const out: string[] = [];
|
|
138
|
+
out.push("<!doctype html><html><head><meta charset='utf-8'>");
|
|
139
|
+
out.push(`<title>${escapeHtml(ctx.title)}</title>`);
|
|
140
|
+
out.push(
|
|
141
|
+
"<link rel='stylesheet' href='https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap'>"
|
|
142
|
+
);
|
|
143
|
+
out.push(`<style>${PRINT_STYLES}</style></head><body>`);
|
|
144
|
+
if (ctx.eyebrow) out.push(`<p class='eyebrow'>${escapeHtml(ctx.eyebrow)}</p>`);
|
|
145
|
+
out.push(`<h1>${escapeHtml(ctx.heading)}</h1>`);
|
|
146
|
+
ctx.meta.forEach((line) => out.push(`<p class='meta'>${escapeHtml(line)}</p>`));
|
|
147
|
+
if (ctx.filterNote) out.push(`<p class='meta note'>${escapeHtml(ctx.filterNote)}</p>`);
|
|
148
|
+
|
|
149
|
+
ctx.groups.forEach((group) => {
|
|
150
|
+
if (group.name) {
|
|
151
|
+
out.push(`<div class='grp'>${escapeHtml(group.name)} · ${group.rows.length}</div>`);
|
|
152
|
+
}
|
|
153
|
+
out.push(`<table>${colgroup}<thead>${headRow}</thead><tbody>`);
|
|
154
|
+
group.rows.forEach((row) => {
|
|
155
|
+
out.push(
|
|
156
|
+
"<tr>" +
|
|
157
|
+
cols.map((c) => `<td>${printCellHtml(row, c.key)}</td>`).join("") +
|
|
158
|
+
(tick ? "<td class='chk'><span class='box'></span></td>" : "") +
|
|
159
|
+
"</tr>"
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
out.push("</tbody></table>");
|
|
163
|
+
});
|
|
164
|
+
if (ctx.groups.length === 0) out.push(`<p class='meta'>${escapeHtml(ctx.emptyText)}</p>`);
|
|
165
|
+
|
|
166
|
+
if (ctx.notes) {
|
|
167
|
+
out.push(
|
|
168
|
+
`<div class='notes'><div class='grp'>Notes</div><div class='notes-body'>${escapeHtml(ctx.notes)}</div></div>`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
out.push("</body></html>");
|
|
172
|
+
return out.join("");
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**What goes on paper: the view as it stands.** Build the context inside `Block()` from the same
|
|
177
|
+
derived values the screen renders (the columns on show, in their order, hidden ones left out; the
|
|
178
|
+
same groups, sort and filters), so paper and screen cannot disagree. Two meta lines earn their
|
|
179
|
+
place: the date, because a sheet outlives the day it was printed, and a line that says so when a
|
|
180
|
+
search or filter is on. (A printout that is a different document by design, such as a packing slip
|
|
181
|
+
or an invoice, builds its own content, but keeps every other rule on this page.)
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
/* Inside Block(), next to the values it reads. */
|
|
185
|
+
function printContext(): PrintContext {
|
|
186
|
+
return {
|
|
187
|
+
title: `${projectName} — ${listName}`,
|
|
188
|
+
eyebrow: projectName,
|
|
189
|
+
heading: listName,
|
|
190
|
+
meta: ["Printed " + format(new Date(), "MMM d, yyyy")],
|
|
191
|
+
filterNote: hasActiveFilter ? `Filtered view — ${shownRows.length} of ${rows.length} items` : "",
|
|
192
|
+
columns: visibleColumns,
|
|
193
|
+
widths: columnWidths,
|
|
194
|
+
groups: groups, // the grouped, sorted, filtered rows the table renders, as PrintRow
|
|
195
|
+
tickBox: true,
|
|
196
|
+
notes: notes.trim(),
|
|
197
|
+
emptyText: rows.length === 0 ? "Nothing on this list yet." : "No items match this filter.",
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## 2. Open it from the click
|
|
203
|
+
|
|
204
|
+
```tsx
|
|
205
|
+
function handlePrint() {
|
|
206
|
+
// FIRST, and synchronously: the click's user activation is what gets the window past the
|
|
207
|
+
// pop-up blocker. No await, .then() or setTimeout in front of this line.
|
|
208
|
+
const win = window.open("", "_blank", "width=900,height=700");
|
|
209
|
+
if (!win) {
|
|
210
|
+
toast.error("Your browser blocked the print window. Allow pop-ups for this site and try again.");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
writePrintout(win, buildPrintHtml(printContext()));
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- **Nothing asynchronous before `window.open`.** A browser lets a page open a window only while
|
|
218
|
+
the user's click is fresh. An `await` (fetching the data on click, say), a `.then()` or a
|
|
219
|
+
`setTimeout` in front of it spends that allowance, and the window is blocked some of the time,
|
|
220
|
+
in some browsers: the worst kind of bug to chase. Section 4 makes sure the data is already there
|
|
221
|
+
when the button can be pressed, so there is nothing to wait for.
|
|
222
|
+
- **`null` means blocked.** Say how to fix it (the toast above) instead of failing silently.
|
|
223
|
+
- **No `noopener` or `noreferrer` in the features.** With either one, `window.open` returns `null`
|
|
224
|
+
even though the window opened: there is no handle to write the printout into, and the code reads
|
|
225
|
+
the `null` as "blocked".
|
|
226
|
+
- **`width` / `height` make it a pop-up window.** A features string like the one above makes
|
|
227
|
+
Chromium and Firefox open a separate window at that size; with no features string it is a new
|
|
228
|
+
tab. Either meets the rule. Pick one and make every Print in the app the same call, so every
|
|
229
|
+
Print behaves the same; nothing but discipline keeps two blocks alike (SKILL.md Hard
|
|
230
|
+
Constraint 22).
|
|
231
|
+
- **The block that has the data writes the printout itself.** Do not route its own button through
|
|
232
|
+
`?print=1` (section 5): that reloads the whole app and every query in the new window to print
|
|
233
|
+
data this page already holds.
|
|
234
|
+
|
|
235
|
+
## 3. Print only when it is ready
|
|
236
|
+
|
|
237
|
+
```tsx
|
|
238
|
+
/* Writes the printout into `win` and prints it once it is ready. `win` is a window of its own or,
|
|
239
|
+
for a ?print=1 open (section 5), this very window, whose page the printout then replaces.
|
|
240
|
+
Ready means the font stylesheets and every image have loaded or failed, and then the web fonts
|
|
241
|
+
are in: a print fired earlier comes out with empty squares where the photos go, or in the
|
|
242
|
+
fallback font. It never waits more than 4s, then gives the layout a 250ms beat, because some
|
|
243
|
+
browsers otherwise print a blank first page. */
|
|
244
|
+
function writePrintout(win: Window, html: string) {
|
|
245
|
+
win.document.open();
|
|
246
|
+
win.document.write(html);
|
|
247
|
+
win.document.close();
|
|
248
|
+
win.focus();
|
|
249
|
+
|
|
250
|
+
let fired = false;
|
|
251
|
+
const fire = () => {
|
|
252
|
+
if (fired || win.closed) return;
|
|
253
|
+
fired = true;
|
|
254
|
+
window.setTimeout(() => {
|
|
255
|
+
if (!win.closed) win.print();
|
|
256
|
+
}, 250);
|
|
257
|
+
};
|
|
258
|
+
window.setTimeout(fire, 4000); // the cap: one slow or hung request never holds the print hostage
|
|
259
|
+
|
|
260
|
+
const settled = (el: HTMLElement) =>
|
|
261
|
+
new Promise((resolve) => {
|
|
262
|
+
el.addEventListener("load", resolve);
|
|
263
|
+
el.addEventListener("error", resolve); // a failed image or stylesheet must not hold it either
|
|
264
|
+
});
|
|
265
|
+
const waits: Promise<unknown>[] = [];
|
|
266
|
+
// The stylesheets too: document.fonts.ready does not wait for a font stylesheet still in flight.
|
|
267
|
+
win.document.querySelectorAll<HTMLLinkElement>("link[rel='stylesheet']").forEach((link) => {
|
|
268
|
+
if (!link.sheet) waits.push(settled(link));
|
|
269
|
+
});
|
|
270
|
+
Array.from(win.document.images).forEach((img) => {
|
|
271
|
+
if (!img.complete) waits.push(settled(img));
|
|
272
|
+
});
|
|
273
|
+
Promise.all(waits)
|
|
274
|
+
.then(() => win.document.fonts?.ready) // with the stylesheets in, this covers the font files
|
|
275
|
+
.then(fire, fire);
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
- **Stylesheets and images first, then the fonts.** Those are what go missing on paper. The
|
|
280
|
+
stylesheet wait matters because `document.fonts.ready` only counts the fonts the document
|
|
281
|
+
already knows it needs. Measured in Chromium on 2026-09-30: with a font stylesheet taking 1.5s,
|
|
282
|
+
`fonts.ready` resolved at once and the print fired at 255ms; waiting for the `<link>` first held
|
|
283
|
+
the print until the font file had arrived. (The live-verified deployment waited on `fonts.ready`
|
|
284
|
+
and the images only. Photos usually arrive after the stylesheet, which hides the gap; without
|
|
285
|
+
photos, the brand font can miss the paper.)
|
|
286
|
+
- **`fired` makes it print once**, whichever comes first: the cap or the waits.
|
|
287
|
+
- **Guard `win.closed`.** The user can close the window during the wait.
|
|
288
|
+
|
|
289
|
+
## 4. The Print button waits for the data
|
|
290
|
+
|
|
291
|
+
A printout built before the data is in is a skeleton on paper, or worse, the first page of
|
|
292
|
+
records, which looks complete. Keep Print disabled until every query has succeeded **and every
|
|
293
|
+
page is fetched** (`hasNextPage` false on each; the auto-load-all effect in
|
|
294
|
+
[../datasources/reading.md](../datasources/reading.md#loading-all-records-auto-pagination) is what
|
|
295
|
+
gets it there), and say why on hover:
|
|
296
|
+
|
|
297
|
+
```tsx
|
|
298
|
+
const dataReady =
|
|
299
|
+
itemsResult.status === "success" &&
|
|
300
|
+
!itemsResult.hasNextPage &&
|
|
301
|
+
vendorsResult.status === "success" &&
|
|
302
|
+
!vendorsResult.hasNextPage;
|
|
303
|
+
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
onClick={handlePrint}
|
|
307
|
+
disabled={!dataReady}
|
|
308
|
+
title={dataReady ? "Opens the printout in a new window" : "Loading…"}
|
|
309
|
+
className="inline-flex items-center gap-1.5 … disabled:cursor-wait disabled:opacity-60"
|
|
310
|
+
>
|
|
311
|
+
<Printer className="h-4 w-4" />
|
|
312
|
+
Print
|
|
313
|
+
</button>
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
If the printout reads anything else from state that arrives after the data, such as a saved
|
|
317
|
+
layout or view settings hydrated from a record, add its ready flag to the same condition
|
|
318
|
+
(`viewReady` in section 5).
|
|
319
|
+
|
|
320
|
+
## 5. Printing from another page: `?print=1`
|
|
321
|
+
|
|
322
|
+
A Print control on a page that does not hold the data (a card on an index page, say) needs the
|
|
323
|
+
target page to build the printout. But a page that has just loaded cannot open a window (there is
|
|
324
|
+
no user gesture, so the pop-up blocker stops it), and it must not `window.print()` itself (the
|
|
325
|
+
rule at the top). So the two pages split the work.
|
|
326
|
+
|
|
327
|
+
**The linking page opens the window, straight from the click**, pointed at the target page with a
|
|
328
|
+
flag:
|
|
329
|
+
|
|
330
|
+
```tsx
|
|
331
|
+
<a
|
|
332
|
+
href={`/list?recordId=${encodeURIComponent(record.id)}&print=1`}
|
|
333
|
+
onClick={(e) => {
|
|
334
|
+
// A window of its own, the same call as every other Print. preventDefault ONLY when it
|
|
335
|
+
// opened: if a pop-up blocker returns null, the link still works, in this tab.
|
|
336
|
+
const win = window.open(e.currentTarget.href, "_blank", "width=900,height=700");
|
|
337
|
+
if (win) e.preventDefault();
|
|
338
|
+
}}
|
|
339
|
+
className="…"
|
|
340
|
+
>
|
|
341
|
+
<Printer className="h-3.5 w-3.5" />
|
|
342
|
+
Print
|
|
343
|
+
</a>
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
A real `<a href>` rather than a button, so the fallback costs nothing and the URL is a real one.
|
|
347
|
+
Keep `noopener` out here too: `window.open` would return `null` although the window opened, the
|
|
348
|
+
handler would not `preventDefault`, and the tab would navigate as well, giving two printouts.
|
|
349
|
+
|
|
350
|
+
**The target block turns its own window into the printout.** It reads the flag once, shows a
|
|
351
|
+
one-line holding state instead of its UI, and, once the data and anything else the printout reads
|
|
352
|
+
are in, replaces its own document with the printout and prints:
|
|
353
|
+
|
|
354
|
+
```tsx
|
|
355
|
+
export default function Block() {
|
|
356
|
+
// Read once, at mount.
|
|
357
|
+
const [printOnLoad, setPrintOnLoad] = useState(() => {
|
|
358
|
+
try {
|
|
359
|
+
const p = new URLSearchParams(window.location.search).get("print");
|
|
360
|
+
return p === "1" || p === "true";
|
|
361
|
+
} catch (e) {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// … the data hooks, the auto-load-all effects, dataReady (section 4), anyQueryFailed (any of
|
|
367
|
+
// them in status "error"), and viewReady if a saved layout is hydrated into state (drop it
|
|
368
|
+
// from the effect below if there is none) …
|
|
369
|
+
|
|
370
|
+
/* Runs once: printOnLoad goes false first, so a later refetch cannot print a second time.
|
|
371
|
+
printContext is declared further down and reads values computed during the render; the
|
|
372
|
+
effect runs after the render, by which time they are all set. */
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
if (!printOnLoad || !dataReady || !viewReady) return;
|
|
375
|
+
setPrintOnLoad(false);
|
|
376
|
+
writePrintout(window, buildPrintHtml(printContext()));
|
|
377
|
+
}, [printOnLoad, dataReady, viewReady]);
|
|
378
|
+
|
|
379
|
+
// … every other hook: ALL of them above the holding return below (Hard Constraint 19) …
|
|
380
|
+
|
|
381
|
+
if (printOnLoad) {
|
|
382
|
+
// This window exists only to become the printout: a holding line, never the full UI.
|
|
383
|
+
return (
|
|
384
|
+
<div className="container py-0">
|
|
385
|
+
<div className="content">
|
|
386
|
+
<div className="px-8 pt-10 pb-12 text-sm text-muted-foreground">
|
|
387
|
+
{anyQueryFailed
|
|
388
|
+
? "Couldn't load this to print it. Reload this window to try again."
|
|
389
|
+
: "Preparing the printout…"}
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// … the normal view …
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
- **The ready flags are the effect's dependencies.** It has to run again each time one of them
|
|
401
|
+
flips; an empty dependency array runs it once, at mount, before any data exists.
|
|
402
|
+
- **A holding line, not the full UI.** Rendering the normal view meanwhile flashes the whole app
|
|
403
|
+
(filters, buttons, a table filling in) for a second before it turns into paper.
|
|
404
|
+
- **`writePrintout(window, …)` replaces the Softr page in that window.** `document.open()` clears
|
|
405
|
+
the app's DOM, header and footer included, and the printout is all that is left. Whatever React
|
|
406
|
+
renders after that goes into a root that is no longer in the document.
|
|
407
|
+
- **The fallback.** If the linking page's pop-up was blocked, the link opened this URL in the
|
|
408
|
+
user's own tab, and the printout replaces the app there. The link still works; it is just less
|
|
409
|
+
graceful.
|
|
410
|
+
|
|
411
|
+
## 6. Layout on paper
|
|
412
|
+
|
|
413
|
+
The CSS in section 1 carries all of this; each rule is there for a reason.
|
|
414
|
+
|
|
415
|
+
- **One `<colgroup>`, shared by every group's table, with `table-layout: fixed`.** A grouped view
|
|
416
|
+
prints as one table per group. With automatic layout each table sizes its columns to its own
|
|
417
|
+
content, and the columns zig-zag from one group to the next. A fixed layout takes its widths
|
|
418
|
+
from the `<col>` elements, and one shared `<colgroup>` gives every table the same ones.
|
|
419
|
+
Percentages in proportion to the on-screen widths keep the paper recognisable as the screen.
|
|
420
|
+
- **`overflow-wrap: anywhere` on every cell**, so a long SKU or URL wraps inside its fixed column
|
|
421
|
+
instead of running into the next one.
|
|
422
|
+
- **`vertical-align: middle` on every `td`.** A room or category that wraps to two lines otherwise
|
|
423
|
+
leaves the one-line values in its row hanging from the top. (Verified: every cell 0px off its
|
|
424
|
+
row's middle.)
|
|
425
|
+
- **A tick-box column is a bordered block, centred:** `.box{display:block;margin:0 auto;…}` in a
|
|
426
|
+
`text-align:center` cell, under a title-less `<th>` that carries an `aria-label`. A block sits
|
|
427
|
+
on no text line, so no line-height can nudge it off the middle; an inline box sits on the text
|
|
428
|
+
baseline, and the line's descender space pushes it off-centre.
|
|
429
|
+
- **`tbody tr { break-inside: avoid }`**, so a row never splits across two pages. The same for the
|
|
430
|
+
notes.
|
|
431
|
+
- **Backgrounds do not print by default.** Browsers drop background colours and images unless the
|
|
432
|
+
user ticks "Background graphics". Where a background carries meaning (a placeholder square, a
|
|
433
|
+
status fill), set `-webkit-print-color-adjust: exact; print-color-adjust: exact` on it, and draw
|
|
434
|
+
photos as `<img>`, never as a CSS `background-image`.
|
|
435
|
+
- **Images at a fixed size with `object-fit: cover`**, and an empty square of the same size where
|
|
436
|
+
a row has none, so the text beside them keeps one straight edge.
|
|
437
|
+
- **`@page { margin: 14mm }`** is the margin on paper; the `body` margin is for the window on
|
|
438
|
+
screen.
|
|
439
|
+
|
|
440
|
+
## 7. Gotchas and testing
|
|
441
|
+
|
|
442
|
+
**Attachment URLs work as they are.** Softr Database attachment URLs are pre-signed links that
|
|
443
|
+
carry their auth in the query string, so the new window loads them with no cookie or header
|
|
444
|
+
(verified 2026-09-30); any file URL that needs no cookie behaves the same. They expire after a
|
|
445
|
+
couple of hours and are re-signed on every fetch, so build the printout from what the block has
|
|
446
|
+
just fetched, never from a URL saved earlier.
|
|
447
|
+
|
|
448
|
+
**Capture the window with `page.waitForEvent("popup")`** (Playwright). The printout is a popup of
|
|
449
|
+
the page that was clicked, for the Print button (`window.open("")`) and for a `?print=1` link on
|
|
450
|
+
another page (`window.open(href)`) alike:
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
const [popup] = await Promise.all([
|
|
454
|
+
page.waitForEvent("popup"),
|
|
455
|
+
page.getByRole("button", { name: /print/i }).click(),
|
|
456
|
+
]);
|
|
457
|
+
|
|
458
|
+
// Replace print() before it fires, so no dialog blocks the run and the test sees when it fired.
|
|
459
|
+
// For the Print button the document is already written by the time you hold the popup, and
|
|
460
|
+
// print() comes at least 250ms later.
|
|
461
|
+
await popup.evaluate(() => {
|
|
462
|
+
window.print = () => {
|
|
463
|
+
(window as any).__printedWithImages = Array.from(document.images).every((img) => img.complete);
|
|
464
|
+
};
|
|
465
|
+
});
|
|
466
|
+
await expect
|
|
467
|
+
.poll(() => popup.evaluate(() => (window as any).__printedWithImages), { timeout: 10_000 })
|
|
468
|
+
.toBe(true); // the 4s cap plus the 250ms beat is too close to the 5s default
|
|
469
|
+
|
|
470
|
+
// A plain document: no shadow roots and no Softr chrome, so ordinary locators work.
|
|
471
|
+
await expect(popup.locator(".softr-topbar")).toHaveCount(0);
|
|
472
|
+
await expect(popup.locator("tbody tr")).toHaveCount(expectedRows);
|
|
473
|
+
|
|
474
|
+
// Each tick box's centre against its row's.
|
|
475
|
+
const offsets = await popup.evaluate(() =>
|
|
476
|
+
Array.from(document.querySelectorAll("tbody tr")).map((tr) => {
|
|
477
|
+
const row = tr.getBoundingClientRect();
|
|
478
|
+
const box = tr.querySelector(".box")!.getBoundingClientRect();
|
|
479
|
+
return Math.abs(box.top + box.height / 2 - (row.top + row.height / 2));
|
|
480
|
+
})
|
|
481
|
+
);
|
|
482
|
+
expect(Math.max(...offsets)).toBeLessThan(1);
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
For a `?print=1` link, click the link instead. That window loads the Softr page first, so wait
|
|
486
|
+
for it to *become* the printout before asserting on it:
|
|
487
|
+
`await expect(popup).toHaveTitle("Project name — List name")`. Stub `print` there once the Softr
|
|
488
|
+
page has loaded (`await popup.waitForLoadState()`): `document.open()` keeps a `print` set on the
|
|
489
|
+
window before it (checked in Chromium, 2026-09-30). The printout has no print-only CSS, so the
|
|
490
|
+
window lays it out as the paper will; narrow it to about the paper's printable width (roughly
|
|
491
|
+
700px for A4 or Letter at 14mm margins) to see the wrapping the paper gets.
|
|
492
|
+
|
|
493
|
+
**Do not mock a popup printout's images or fonts with `route()`.** The Print button writes the
|
|
494
|
+
printout in the same task that opens the window, so its first requests go out before Playwright
|
|
495
|
+
has attached to the popup. With Playwright attached over CDP (seen 2026-09-30) they hang: no
|
|
496
|
+
`request` event, the route handler never runs, the images stay pending, and the print falls
|
|
497
|
+
through to the 4s cap, which looks exactly like a broken wait. Use real URLs or `data:` URLs in
|
|
498
|
+
the popup, or mock on a page that loaded normally (the `?print=1` path), where routing works.
|
|
499
|
+
|
|
500
|
+
**Preview links pin the app version they were minted on.** A `preview_app` session keeps serving
|
|
501
|
+
the version it was opened on, so after pushing a change, mint a fresh preview link before you
|
|
502
|
+
verify anything; otherwise you are testing the old Print. (What else a preview link is, and why
|
|
503
|
+
it is never shared: [softr-mcp.md](softr-mcp.md#application-management-tools).)
|
|
504
|
+
|
|
505
|
+
**A browser that is not painting does not run the page.** A hidden browser pane or a background
|
|
506
|
+
tab fires no `requestAnimationFrame` and no IntersectionObserver callbacks, so anything that waits
|
|
507
|
+
on them (a reveal-on-scroll, lazy content, a check timed off a frame) stalls there, and a check
|
|
508
|
+
fails for reasons that have nothing to do with your code. Verify in a visible window or a
|
|
509
|
+
headless browser.
|
|
510
|
+
|
|
511
|
+
## Checklist before shipping a Print control
|
|
512
|
+
|
|
513
|
+
- [ ] Print opens a window of its own: `window.open("", "_blank", …)`, first thing in the click
|
|
514
|
+
handler, nothing asynchronous before it. No `window.print()` on the Softr page, no in-page
|
|
515
|
+
print view
|
|
516
|
+
- [ ] `null` → a toast telling the user to allow pop-ups; no `noopener` / `noreferrer` in the
|
|
517
|
+
features
|
|
518
|
+
- [ ] The same `window.open` features string as every other Print in the app
|
|
519
|
+
- [ ] Every interpolated value through `escapeHtml` (`& < > " '`, attribute values included)
|
|
520
|
+
- [ ] The printout carries its own `<style>` (no Tailwind classes), its fonts' `<link>`, a
|
|
521
|
+
`<title>` with the context, and an `@page` margin
|
|
522
|
+
- [ ] It prints once the stylesheets, fonts and images are in (capped at ~4s), after a 250ms
|
|
523
|
+
beat, with `win.closed` guarded
|
|
524
|
+
- [ ] Print is disabled until every query has succeeded and every page is fetched (and any saved
|
|
525
|
+
layout is applied), with a `title` that says why
|
|
526
|
+
- [ ] Printing from another page: the linking page opens the window (`preventDefault` only when
|
|
527
|
+
it opened); the target block shows "Preparing the printout…", then replaces its own
|
|
528
|
+
document once the ready flags are set, once
|
|
529
|
+
- [ ] Paper: one shared `<colgroup>` + `table-layout: fixed`; `vertical-align: middle`;
|
|
530
|
+
`break-inside: avoid` on rows; `print-color-adjust: exact` where a background must print;
|
|
531
|
+
photos as fixed-size `<img>`
|
|
532
|
+
- [ ] Verified on a freshly minted preview link, with the window captured by
|
|
533
|
+
`waitForEvent("popup")`
|
|
@@ -8,13 +8,13 @@ the obvious choices:
|
|
|
8
8
|
|---|---|
|
|
9
9
|
| Native `<select>` | Hands the list to the OS. No keyword filter, none of your styling, and on macOS it paints a grey slab over the page. Fine for 5 options, unusable at 90. |
|
|
10
10
|
| shadcn `<Select>` / `<Command>` | **Portals to `document.body`, which is outside the block's shadow root**, so the styles arrive stripped. It also cannot be searched. |
|
|
11
|
-
| `Combo` (below) | Local DOM, brand-styled, keyword filter, A→Z, keyboard, create-new, drop-up. |
|
|
11
|
+
| `Combo` (below) | Local DOM, brand-styled, keyword filter, A→Z, keyboard, create-new, clip-aware drop-up. |
|
|
12
12
|
|
|
13
13
|
Copy the component into the block. A Vibe block is one self-contained file — there is no
|
|
14
14
|
shared module to import, so each block carries its own copy. Keep one canonical copy in the
|
|
15
15
|
project (e.g. `Assets/Softr App/Shared/combo.jsx`) and port changes from there.
|
|
16
16
|
|
|
17
|
-
## The
|
|
17
|
+
## The four things that will bite you
|
|
18
18
|
|
|
19
19
|
**1. Click-outside must use `composedPath()`, not `contains()`.**
|
|
20
20
|
By the time a click reaches `document`, the shadow DOM has *retargeted* its `target` to the
|
|
@@ -44,6 +44,182 @@ single most common Vibe Coding bug and it looks like a platform fault.
|
|
|
44
44
|
**3. `onMouseDown` on a row must `preventDefault()`.**
|
|
45
45
|
Otherwise focus leaves the search input before the click resolves.
|
|
46
46
|
|
|
47
|
+
**4. The panel is clipped by any ancestor whose `overflow` is not `visible`.**
|
|
48
|
+
The panel is `position: absolute` inside the trigger's `relative` wrapper, in the block's own
|
|
49
|
+
DOM, because it cannot portal out of the shadow root (see *Why not portal* below). Its
|
|
50
|
+
containing block is that wrapper, so *every* ancestor whose `overflow` is `hidden`, `auto`,
|
|
51
|
+
`scroll` or `clip` clips it, however far up. In Tailwind that is `overflow-hidden`,
|
|
52
|
+
`overflow-auto`, `overflow-y-auto`, `overflow-x-auto` (an `overflow-x` of `hidden`, `auto` or
|
|
53
|
+
`scroll` turns `overflow-y: visible` into `auto`, so it clips vertically too), `truncate` (it
|
|
54
|
+
sets `overflow: hidden`) and `line-clamp-*` (so does that). A clipped `<td>` is the height of
|
|
55
|
+
its row, and the menu opens inside it. Three rules follow.
|
|
56
|
+
|
|
57
|
+
**Rule 1 — never put a clipping class on an element that contains a `Combo`:** a `<td>`, a
|
|
58
|
+
card, a flex cell, anything between the Combo and the scroller it belongs to. If a chip or
|
|
59
|
+
label inside the trigger can overflow, bound it at the chip: `truncate` (or `overflow-hidden`)
|
|
60
|
+
plus `min-w-0` on the chip itself. The trigger is a flex row, and `min-w-0` is what lets a flex
|
|
61
|
+
item shrink below its text — strictly redundant while the chip clips itself, load-bearing the
|
|
62
|
+
moment the ellipsis moves to a span inside it. Clipping at the chip clips the chip. Clipping at
|
|
63
|
+
the cell clips the menu.
|
|
64
|
+
|
|
65
|
+
```jsx
|
|
66
|
+
<td className="px-4">{/* no overflow class on the cell */}
|
|
67
|
+
<Combo
|
|
68
|
+
bare
|
|
69
|
+
triggerContent={<span className="min-w-0 truncate px-2" style={chipStyle}>{label}</span>}
|
|
70
|
+
…
|
|
71
|
+
/>
|
|
72
|
+
</td>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Rule 2 — decide the drop-up and the list's height against the clipping ancestors, not the
|
|
76
|
+
window.** `window.innerHeight - rect.bottom` cannot see scroll containers: a table with its own
|
|
77
|
+
`max-height` + `overflow: auto` scroller, a dialog body with `overflow-y-auto`, a horizontally
|
|
78
|
+
scrollable table wrapper. A row near the bottom of one of those opens its menu downward into
|
|
79
|
+
the scroller's hidden area while the window still has plenty of room. Measure inside the strip
|
|
80
|
+
the panel can actually paint into:
|
|
81
|
+
|
|
82
|
+
```jsx
|
|
83
|
+
var COMBO_LIST_MAX = 256; // the list's normal ceiling (max-h-64)
|
|
84
|
+
var COMBO_LIST_MIN = 120; // the floor: below this, accept a clip rather than a useless list
|
|
85
|
+
|
|
86
|
+
/* The strip of screen the panel can paint into: the viewport, cut down by every ancestor
|
|
87
|
+
that clips vertically. overflowY is enough on its own: an overflow-x of hidden, auto or
|
|
88
|
+
scroll already turns a visible overflow-y into auto in the computed style. */
|
|
89
|
+
function comboClipBox(node) {
|
|
90
|
+
var top = 0;
|
|
91
|
+
var bottom = window.innerHeight;
|
|
92
|
+
var el = node;
|
|
93
|
+
while (el && el !== document.body && el !== document.documentElement) {
|
|
94
|
+
// clientHeight is 0 for the boxes overflow does not apply to (inline, display: contents)
|
|
95
|
+
if (el.clientHeight > 0 && window.getComputedStyle(el).overflowY !== "visible") {
|
|
96
|
+
var r = el.getBoundingClientRect();
|
|
97
|
+
var t = r.top + el.clientTop; // the clip edge is the padding box: inside the border
|
|
98
|
+
var b = t + el.clientHeight; // and above a horizontal scrollbar
|
|
99
|
+
if (t > top) top = t;
|
|
100
|
+
if (b < bottom) bottom = b;
|
|
101
|
+
}
|
|
102
|
+
// Where the parent chain ends at the shadow root, carry on from its host.
|
|
103
|
+
el = el.parentElement || el.getRootNode().host || null;
|
|
104
|
+
}
|
|
105
|
+
return { top: top, bottom: bottom };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* chrome = the panel's height outside the list: the search box and the borders. */
|
|
109
|
+
function comboPlace(wrapper, chrome) {
|
|
110
|
+
var clip = comboClipBox(wrapper);
|
|
111
|
+
var r = wrapper.getBoundingClientRect();
|
|
112
|
+
var below = clip.bottom - r.bottom - 10; // the 2px gap to the trigger + 8px to spare
|
|
113
|
+
var above = r.top - clip.top - 10;
|
|
114
|
+
var up = below < chrome + COMBO_LIST_MAX && above > below;
|
|
115
|
+
var room = (up ? above : below) - chrome;
|
|
116
|
+
return { up: up, listMax: Math.max(COMBO_LIST_MIN, Math.min(COMBO_LIST_MAX, room)) };
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Call it when the panel opens, and give the list its height as an inline style in place of
|
|
121
|
+
`max-h-64`:
|
|
122
|
+
|
|
123
|
+
```jsx
|
|
124
|
+
var [listMax, setListMax] = useState(COMBO_LIST_MAX);
|
|
125
|
+
|
|
126
|
+
// in toggle(), before setOpen(true):
|
|
127
|
+
if (rootRef.current) {
|
|
128
|
+
var place = comboPlace(rootRef.current, searchable ? 51 : 2);
|
|
129
|
+
setDropUp(place.up);
|
|
130
|
+
setListMax(place.listMax);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// the list:
|
|
134
|
+
<div ref={listRef} role="listbox" className="overflow-y-auto py-1" style={{ maxHeight: listMax }}>
|
|
135
|
+
{/* rows */}
|
|
136
|
+
</div>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Downward when the whole panel fits below, otherwise toward the larger side, with the list
|
|
140
|
+
capped to the room it gets. `chrome` is the panel's height outside the list — about 51px with
|
|
141
|
+
the search box (`p-2` around an `h-8` input, a 1px rule, the panel's two borders) and 2px
|
|
142
|
+
without; measure yours if the panel differs. The fit test assumes a full-height list, so a
|
|
143
|
+
three-option menu may flip up when it would have fit below, which costs nothing. The walk
|
|
144
|
+
starts at the wrapper, whose own overflow would clip the panel too; it leaves the shadow root
|
|
145
|
+
through its host, so a clipping container outside the block still counts; and it stops below
|
|
146
|
+
`<body>`, because `body` and `html` hand their overflow to the viewport — their computed
|
|
147
|
+
`overflow` can say `hidden` while they clip nothing. The four cases it has to get right: a row
|
|
148
|
+
mid-way down a tall table scroller opens down; the last visible row of a scroller whose bottom
|
|
149
|
+
edge is mid-window opens up, inside the scroller (the window-only rule opened it down, into
|
|
150
|
+
the hidden part); a picker at the bottom of a dialog body opens up, inside the dialog; a filter
|
|
151
|
+
row at the window's bottom edge opens up, as before. All four, plus a scroller outside the
|
|
152
|
+
shadow root, checked in Chromium on 2026-09-30 on a test page — not yet in a deployed block.
|
|
153
|
+
|
|
154
|
+
Rule 2 does not rescue a clipped cell. The cell is the height of its row, so neither side has
|
|
155
|
+
room, the list falls to its 120px floor and is clipped anyway. Rule 1 is not optional.
|
|
156
|
+
|
|
157
|
+
**The same box decides which edge the panel hangs from.** A panel hung from the trigger's left
|
|
158
|
+
edge with `width: max-content` runs past the right edge of a table's scroll box when the trigger
|
|
159
|
+
sits in the last column. In ROSIE the Location menu ran about 15px over, and the old
|
|
160
|
+
`scrollIntoView` then slid the whole table sideways to reveal it. Measure the box's left and right
|
|
161
|
+
edges as well (padding box, tested on `overflowX`), and when the room to the right of the trigger
|
|
162
|
+
is short (under ~300px) and there is more to the left, anchor the panel with `right: 0` instead of
|
|
163
|
+
`left: 0` and cap its `maxWidth` to the room on that side. Rows are a fixed height in this
|
|
164
|
+
component, so the fit test can also count the real rows instead of assuming a full list, and a
|
|
165
|
+
four-option menu near an edge stops flipping for room it will never use. ROSIE's
|
|
166
|
+
`Shared/combo.jsx` (2026-09-30) is the worked version: one `comboClipBox` returning all four
|
|
167
|
+
edges, one `comboPlacement` returning `{ up, right, listMax, maxW }`, deployed in twelve blocks.
|
|
168
|
+
|
|
169
|
+
**Rule 3 — keep the active row visible by scrolling the list, never with `scrollIntoView`.**
|
|
170
|
+
`scrollIntoView` scrolls *every* scrollable ancestor until the element shows, and an
|
|
171
|
+
`overflow: hidden` box is still scrollable from script. In a clipped cell it scrolls the
|
|
172
|
+
cell's content until the option shows, pushing the trigger out of view; near the edge of a
|
|
173
|
+
table it scrolls whatever the menu hangs out of — the table's own scroller, the page — so the
|
|
174
|
+
table jumps as the menu opens. Scroll the list element and nothing else:
|
|
175
|
+
|
|
176
|
+
```jsx
|
|
177
|
+
useEffect(
|
|
178
|
+
function () {
|
|
179
|
+
var list = listRef.current;
|
|
180
|
+
if (!open || !list) return;
|
|
181
|
+
var el = list.querySelector('[data-active="true"]');
|
|
182
|
+
if (!el) return;
|
|
183
|
+
var lr = list.getBoundingClientRect();
|
|
184
|
+
var er = el.getBoundingClientRect();
|
|
185
|
+
var viewTop = lr.top + list.clientTop; // inside the list's top border
|
|
186
|
+
var viewBottom = viewTop + list.clientHeight; // clientHeight excludes border and scrollbar
|
|
187
|
+
if (er.top < viewTop) list.scrollTop -= viewTop - er.top;
|
|
188
|
+
else if (er.bottom > viewBottom) list.scrollTop += er.bottom - viewBottom;
|
|
189
|
+
},
|
|
190
|
+
[open, activeIdx, query]
|
|
191
|
+
);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Rects rather than `offsetTop`: a row's `offsetParent` is the nearest positioned ancestor,
|
|
195
|
+
which is the panel, not the list, so `offsetTop` comes out too large by the search box's
|
|
196
|
+
height unless the list is made `position: relative`. Rects need no such arrangement. The
|
|
197
|
+
effect runs after the commit, outside the event cycle Hard Constraint 17's `setTimeout` is
|
|
198
|
+
there to escape — the `scrollIntoView` it replaces ran from the same kind of effect and took
|
|
199
|
+
effect. `focus()` scrolls ancestors the same way, so focus the search box with
|
|
200
|
+
`inputRef.current.focus({ preventScroll: true })`.
|
|
201
|
+
|
|
202
|
+
**Why not portal the panel, or make it `position: fixed`?** A portal to `document.body`
|
|
203
|
+
leaves the shadow root, and the styles stay behind — the shadcn row at the top of this page.
|
|
204
|
+
`position: fixed` inside the shadow root escapes the clipping only while no ancestor has a
|
|
205
|
+
`transform`, `filter`, `perspective`, `contain` or `will-change`: any of those becomes the
|
|
206
|
+
containing block for fixed descendants, and scroll-reveal animations on Softr pages commonly
|
|
207
|
+
leave a transform behind (the same trap as the block-owned header in
|
|
208
|
+
[static-blocks.md](static-blocks.md#block-owned-landing-page-header)). A fixed panel also stops
|
|
209
|
+
moving with its trigger, so it has to be re-placed on every scroll. The clip-aware absolute
|
|
210
|
+
panel has neither problem, which is why it is the design here.
|
|
211
|
+
|
|
212
|
+
**The incident — ROSIE, 2026-09-30.** Three item tables put `overflow-hidden` on the `<td>`
|
|
213
|
+
holding the status chip, as a backstop so the chip, 1–2px too wide at the column's narrowest
|
|
214
|
+
drag width, would clip at its own column instead of painting over the next one. The comment
|
|
215
|
+
beside it said the status menu was portaled to the body, so the clip could not reach it. That
|
|
216
|
+
was true of the shadcn `<Select>` the tables used before and stopped being true when they moved
|
|
217
|
+
to `Combo`; nobody re-checked. The menu opened inside the 48px cell: one and a half options
|
|
218
|
+
showing, the chip scrolled out of view by `scrollIntoView`, the other six unreachable by
|
|
219
|
+
mouse. The Location dropdown in the same rows kept working, because its cell had no overflow
|
|
220
|
+
class. A 2px backstop cost the whole feature. A comment that says how a component renders is
|
|
221
|
+
a claim to verify in the component, not in the comment.
|
|
222
|
+
|
|
47
223
|
## Sort A→Z *inside* the component
|
|
48
224
|
|
|
49
225
|
Sorting at the call site gets forgotten. Do it in the component, with an opt-out:
|
|
@@ -125,16 +301,7 @@ than reaching past the wrapper.
|
|
|
125
301
|
Build `rows` as a single array — the optional *clear* row, then the filtered options, then
|
|
126
302
|
the optional *create* row — so arrow-key navigation has one index to walk. Clamp the active
|
|
127
303
|
index (`Math.min(active, rows.length - 1)`): filtering shrinks the list under the highlight.
|
|
128
|
-
|
|
129
|
-
## Drop up near the fold
|
|
130
|
-
|
|
131
|
-
```jsx
|
|
132
|
-
var r = rootRef.current.getBoundingClientRect();
|
|
133
|
-
var below = window.innerHeight - r.bottom;
|
|
134
|
-
setDropUp(below < 300 && r.top > below);
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
A filter row near the bottom of the viewport otherwise opens into nothing.
|
|
304
|
+
Keep the highlighted row in view by scrolling the list only (rule 3 of item 4 above).
|
|
138
305
|
|
|
139
306
|
## Variants worth having
|
|
140
307
|
|
|
@@ -145,6 +312,9 @@ A filter row near the bottom of the viewport otherwise opens into nothing.
|
|
|
145
312
|
⚠ If any column width in your table is derived from the trigger's chrome, keep the
|
|
146
313
|
chevron the SAME size in both variants. Shrinking it in `bare` silently changes those
|
|
147
314
|
widths in a different file.
|
|
315
|
+
⚠ A `bare` Combo sits in a table cell, which is exactly where `overflow-hidden` and
|
|
316
|
+
`truncate` get added. Keep them off the cell and bound the chip instead — rule 1 of item 4
|
|
317
|
+
in [The four things that will bite you](#the-four-things-that-will-bite-you).
|
|
148
318
|
- **`triggerStyle`** — merged over the defaults, for a trigger that is part of the design
|
|
149
319
|
(a chip painted in its own status colour) rather than a plain field.
|
|
150
320
|
- **`onCreate(text)`** — offers `Add "<typed>"` when nothing matches. If creating the record
|
|
@@ -172,7 +342,12 @@ Everything else — the trigger, the card, the rows — stays flat.
|
|
|
172
342
|
- [ ] Sorted A→Z inside the component, with `autoSort={false}` only where order is meaning
|
|
173
343
|
- [ ] Multi-token filter
|
|
174
344
|
- [ ] Searchable by default; `bare` is click-only; `searchable={false}` only on a short fixed enum the user is setting
|
|
175
|
-
- [ ]
|
|
345
|
+
- [ ] No overflow-clipping class (`overflow-hidden`, `overflow-*-auto`, `truncate`,
|
|
346
|
+
`line-clamp-*`) between the Combo and the scroller it belongs to; an over-wide chip
|
|
347
|
+
bounded at the chip (`min-w-0 truncate`)
|
|
348
|
+
- [ ] Drop-up and list `maxHeight` measured against the clipping ancestors, not the window
|
|
349
|
+
- [ ] Keyboard: ↑ ↓ Enter Esc Tab; the active row kept visible by scrolling the list only
|
|
350
|
+
(never `scrollIntoView`), and the search box focused with `preventScroll`
|
|
176
351
|
- [ ] `aria-haspopup="listbox"`, `aria-expanded`, `role="listbox"` / `role="option"`,
|
|
177
352
|
`aria-selected`, and an `aria-label` on the trigger
|
|
178
353
|
- [ ] Loading and empty states (`"Nothing matches that."`)
|
package/references/softr-mcp.md
CHANGED
|
@@ -197,8 +197,15 @@ shapes, different payload type. The stringification happened on the client side,
|
|
|
197
197
|
calls made while the tool definitions had not been loaded into the model's context (deferred
|
|
198
198
|
schemas), so there was no type to serialise against. **Load the tool's schema before calling it,
|
|
199
199
|
and pass arrays as arrays.** (Empty schemas are real on Softr's *per-application* MCP servers —
|
|
200
|
-
every tool there is advertised as `{"type":"object"}` with a name-only description
|
|
201
|
-
|
|
200
|
+
every tool there is advertised as `{"type":"object"}` with a name-only description.)
|
|
201
|
+
|
|
202
|
+
**Correction, 2026-09-30: the workspace server's tools can arrive schema-less too.** In one
|
|
203
|
+
session every workspace tool loaded through ToolSearch showed only `{"type":"object"}` with a
|
|
204
|
+
name-only description, and a `search_replace` call written with a real array was still sent as a
|
|
205
|
+
string and rejected with the error above. Nothing was written, so the failure is safe, but no
|
|
206
|
+
amount of care on the caller's side gets an array through a schema-less tool. Look at the
|
|
207
|
+
loaded schema before relying on an array argument: if it has no `properties`, go straight to the
|
|
208
|
+
fallback in the table — a full replace, one file per subagent for a large block, byte-verified.
|
|
202
209
|
|
|
203
210
|
**Why the second row is a security problem, not an inconvenience.** Every code push resets the
|
|
204
211
|
block's auto-registered Actions to Softr's defaults, and the default for a `genericActions`
|