softr-vibe-coding 2.5.2 → 2.7.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 +6 -0
- package/README.md +8 -3
- package/SKILL.md +2 -0
- package/package.json +1 -1
- package/references/anti-patterns.md +1 -0
- package/references/common-patterns.md +100 -0
- package/references/searchable-dropdown.md +149 -0
- package/ui-ux-guidelines.md +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ 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.7.0] - 2026-09-09
|
|
8
|
+
- Add the searchable dropdown pattern (Combo) as a reference
|
|
9
|
+
|
|
10
|
+
## [2.6.0] - 2026-09-09
|
|
11
|
+
- Drag-to-reorder: the pattern, the shadow-DOM hit-test trap, and two affordance rules
|
|
12
|
+
|
|
7
13
|
## [2.5.2] - 2026-09-09
|
|
8
14
|
- Permission finding is advisory, not a veto — report severity and leave the call to the builder
|
|
9
15
|
|
package/README.md
CHANGED
|
@@ -220,9 +220,14 @@ softr-vibe-coding/
|
|
|
220
220
|
│ │ # MCP/CLI install (@latest npx + browser step),
|
|
221
221
|
│ │ # extract → poll → findings → generate → write
|
|
222
222
|
│ │ # flow, DESIGN.md anatomy, drift QA
|
|
223
|
-
│
|
|
224
|
-
│
|
|
225
|
-
│
|
|
223
|
+
│ ├── quick-reference.md # Syntax cheat sheet
|
|
224
|
+
│ │ # Imports, hook signatures, mutation shapes,
|
|
225
|
+
│ │ # field mapping, component skeleton
|
|
226
|
+
│ └── searchable-dropdown.md # THE dropdown pattern for blocks
|
|
227
|
+
│ # why native <select> and shadcn <Select> both
|
|
228
|
+
│ # break in the shadow DOM, composedPath()
|
|
229
|
+
│ # click-outside, A-Z inside the component,
|
|
230
|
+
│ # multi-token filter, bare inline-editor variant
|
|
226
231
|
│
|
|
227
232
|
├── tools/ # Bundled CLI scripts (run, not read)
|
|
228
233
|
│ ├── get-airtable-base # Full Airtable base schema export (bash + jq)
|
package/SKILL.md
CHANGED
|
@@ -85,6 +85,7 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
|
|
|
85
85
|
- `useRecordUpdate` payload is `{ recordId, fields: { ... } }` — nested. `useRecordCreate` payload is **flat** (no `fields` wrapper). The two shapes are asymmetric by design (verified live 2026-08-25)
|
|
86
86
|
- 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)
|
|
87
87
|
- No hardcoded domains in links -- use relative paths (`/page?recordId=...`); same-page anchors written relative too (`/#section`)
|
|
88
|
+
- **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)
|
|
88
89
|
- 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))
|
|
89
90
|
- Array-setting rows keyed by **index**, never by a builder-editable field value
|
|
90
91
|
- Media settings that may start empty (`src: ""`) gated with a conditional render or placeholder — never an unconditional `<img src={setting.src}>`
|
|
@@ -167,6 +168,7 @@ For advanced patterns beyond data fetching, load the relevant reference when the
|
|
|
167
168
|
| Embedding third-party libraries with their own CSS (Leaflet, Mapbox, TinyMCE, Quill, FullCalendar) | [references/advanced-integrations.md](references/advanced-integrations.md) |
|
|
168
169
|
| Debugging a broken block, checking patterns before delivery, full violation catalog | [references/anti-patterns.md](references/anti-patterns.md) |
|
|
169
170
|
| Quick syntax check — import paths, hook signatures, mutation call shapes, field mapping | [references/quick-reference.md](references/quick-reference.md) |
|
|
171
|
+
| 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, the `bare` inline-editor variant | [references/searchable-dropdown.md](references/searchable-dropdown.md) |
|
|
170
172
|
| Small reusable patterns — `localStorage` cross-page state, clipboard copy button | [references/common-patterns.md](references/common-patterns.md) |
|
|
171
173
|
| 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) |
|
|
172
174
|
| 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) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "softr-vibe-coding",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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"
|
|
@@ -68,6 +68,7 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
68
68
|
| Hardcoded domain in navigation | Relative paths: `/task-details?recordId=...` |
|
|
69
69
|
| 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 |
|
|
70
70
|
| Emojis in UI | lucide-react icons only |
|
|
71
|
+
| `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 |
|
|
71
72
|
| 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 |
|
|
72
73
|
| 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 |
|
|
73
74
|
| `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 |
|
|
@@ -14,6 +14,7 @@ Small reusable patterns that come up across Vibe Coding blocks but don't warrant
|
|
|
14
14
|
- [Edge-Fade Image Mask (Editorial Hero)](#edge-fade-image-mask-editorial-hero)
|
|
15
15
|
- [Decorative Background Blobs (Editorial Layering)](#decorative-background-blobs-editorial-layering)
|
|
16
16
|
- [Dot-Separated Inline List](#dot-separated-inline-list)
|
|
17
|
+
- [Drag-to-Reorder Rows](#drag-to-reorder-rows)
|
|
17
18
|
|
|
18
19
|
## Cross-Page State with localStorage + URL Parameters
|
|
19
20
|
|
|
@@ -292,3 +293,102 @@ Certifications, feature tags, meta rows: `GMP Manufacturing ● ISO 22716 ● Lo
|
|
|
292
293
|
- **Keep `gap-y-*`** on the container for multi-line rhythm when the list wraps.
|
|
293
294
|
- **If the list is expected to wrap often**, drop the dots and let the gap carry the rhythm — any inline separator looks orphaned at a line break.
|
|
294
295
|
- `aria-hidden="true"` on the glyph — screen readers announce `●` as "black circle" otherwise.
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
## Drag-to-Reorder Rows
|
|
299
|
+
|
|
300
|
+
Reordering a list by dragging, written against a Softr block's constraints. Four of the five decisions
|
|
301
|
+
below are non-obvious, and each one is a bug if you get it wrong.
|
|
302
|
+
|
|
303
|
+
```jsx
|
|
304
|
+
var [drag, setDrag] = useState(null); // { from, over } while dragging, else null
|
|
305
|
+
var [optimisticOrder, setOptimisticOrder] = useState(null); // ids, post-drop, pre-refetch
|
|
306
|
+
var rowElsRef = useRef([]);
|
|
307
|
+
|
|
308
|
+
/* The pointer is CAPTURED, so no other element receives enter/leave — rects are the only
|
|
309
|
+
thing that can answer "what is under the cursor". Compare against each row's MIDPOINT so
|
|
310
|
+
the row you are over is the one that yields. */
|
|
311
|
+
function dropIndex(count, clientY) {
|
|
312
|
+
for (var i = 0; i < count; i++) {
|
|
313
|
+
var el = rowElsRef.current[i];
|
|
314
|
+
if (!el) continue;
|
|
315
|
+
var r = el.getBoundingClientRect();
|
|
316
|
+
if (clientY < r.top + r.height / 2) return i;
|
|
317
|
+
}
|
|
318
|
+
return count - 1;
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
The handle — never the whole row, so text selection and the row's own buttons keep working:
|
|
323
|
+
|
|
324
|
+
```jsx
|
|
325
|
+
<span
|
|
326
|
+
role="button"
|
|
327
|
+
aria-label={"Drag to reorder " + row.name}
|
|
328
|
+
className="touch-none select-none" // or the browser scrolls instead of dragging
|
|
329
|
+
style={{ cursor: "grab" }}
|
|
330
|
+
onPointerDown={function (e) {
|
|
331
|
+
e.preventDefault();
|
|
332
|
+
try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
|
|
333
|
+
setDrag({ from: index, over: index });
|
|
334
|
+
}}
|
|
335
|
+
onPointerMove={function (e) {
|
|
336
|
+
if (!drag) return;
|
|
337
|
+
var over = dropIndex(rows.length, e.clientY);
|
|
338
|
+
if (over !== drag.over) setDrag({ from: drag.from, over: over });
|
|
339
|
+
}}
|
|
340
|
+
onPointerUp={function () {
|
|
341
|
+
if (!drag) return;
|
|
342
|
+
var from = drag.from, to = drag.over;
|
|
343
|
+
setDrag(null);
|
|
344
|
+
if (from !== to) {
|
|
345
|
+
var next = rows.slice();
|
|
346
|
+
next.splice(to, 0, next.splice(from, 1)[0]);
|
|
347
|
+
applyOrder(next);
|
|
348
|
+
}
|
|
349
|
+
}}
|
|
350
|
+
onPointerCancel={function () { setDrag(null); }}
|
|
351
|
+
>
|
|
352
|
+
<GripVertical className="h-3.5 w-3.5" />
|
|
353
|
+
</span>
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
**Pointer capture, not mouse events.** Capture makes the handle the target of every move and of the up
|
|
357
|
+
*wherever the pointer travels*, and obliges the browser to send `pointercancel` if it takes the pointer
|
|
358
|
+
away. Without it, a drag released over another application never delivers its up and the row stays
|
|
359
|
+
stuck mid-drag until a reload.
|
|
360
|
+
|
|
361
|
+
**Measure rects, don't listen for `onPointerEnter` on each row.** While the pointer is captured, no
|
|
362
|
+
other element gets enter/leave at all, so per-row handlers silently never fire. And
|
|
363
|
+
`document.elementFromPoint` is not the escape hatch — inside a block it returns the shadow host (see
|
|
364
|
+
[anti-patterns.md](anti-patterns.md#layout--styling)).
|
|
365
|
+
|
|
366
|
+
**Draw the insertion line with an INSET box-shadow, never a border.** A real 2px border grows the row
|
|
367
|
+
by 2px and shoves every row below it down a notch, so the list crawls under the pointer as the target
|
|
368
|
+
changes:
|
|
369
|
+
|
|
370
|
+
```jsx
|
|
371
|
+
style={Object.assign({}, ROW_STYLE, isTarget
|
|
372
|
+
? (drag.over < drag.from
|
|
373
|
+
? { boxShadow: "inset 0 2px 0 0 " + ACCENT } // landing above
|
|
374
|
+
: { boxShadow: "inset 0 -2px 0 0 " + ACCENT }) // landing below
|
|
375
|
+
: null)}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
**Renumber the whole run — never swap a pair.** A swap cannot express "drop three rows up", and on a
|
|
379
|
+
nullable order field it corrupts the sort: positions start null, so numbering only the two rows that
|
|
380
|
+
moved leaves the rest null, and any "nulls last" comparator then throws every untouched row to the
|
|
381
|
+
bottom the moment the user switches to that sort. Write `position = i + 1` for every row whose slot
|
|
382
|
+
actually changed. The first reorder on a fresh list costs N writes; later ones cost the distance
|
|
383
|
+
travelled.
|
|
384
|
+
|
|
385
|
+
**Hold an optimistic order until the refetch lands.** The position writes are in flight while the
|
|
386
|
+
records still carry their OLD numbers, so re-sorting on those throws the row back to where it was
|
|
387
|
+
dragged from for a beat — which reads as the drag having failed. Apply `optimisticOrder` ahead of both
|
|
388
|
+
sorts and clear it when the refetch resolves. Writes stay sequential (`await mutateAsync` per row, in
|
|
389
|
+
order, stop on first failure — see [../datasources/writing.md](../datasources/writing.md#sequential-multi-row-writes-mutateasync));
|
|
390
|
+
on failure, clear the override and refetch, because a half-applied renumber is worse than none.
|
|
391
|
+
|
|
392
|
+
**Only gate the drag on permissions, not on a sort mode.** If the list has an alternative sort, let the
|
|
393
|
+
drag switch to manual order rather than disabling the handle — see the disabled-control note in
|
|
394
|
+
[../ui-ux-guidelines.md](../ui-ux-guidelines.md#26-finishing-touches).
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# The searchable dropdown (`Combo`)
|
|
2
|
+
|
|
3
|
+
**Use this instead of shadcn's `<Select>` and instead of a native `<select>`, always.**
|
|
4
|
+
A Vibe Coding block renders inside a **shadow DOM**, and that one fact rules out both of
|
|
5
|
+
the obvious choices:
|
|
6
|
+
|
|
7
|
+
| Option | Why it fails in a block |
|
|
8
|
+
|---|---|
|
|
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
|
+
| 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. |
|
|
12
|
+
|
|
13
|
+
Copy the component into the block. A Vibe block is one self-contained file — there is no
|
|
14
|
+
shared module to import, so each block carries its own copy. Keep one canonical copy in the
|
|
15
|
+
project (e.g. `Assets/Softr App/Shared/combo.jsx`) and port changes from there.
|
|
16
|
+
|
|
17
|
+
## The three things that will bite you
|
|
18
|
+
|
|
19
|
+
**1. Click-outside must use `composedPath()`, not `contains()`.**
|
|
20
|
+
By the time a click reaches `document`, the shadow DOM has *retargeted* its `target` to the
|
|
21
|
+
shadow **host**. So the usual `root.contains(e.target)` test calls the panel's own rows
|
|
22
|
+
"outside" and closes the panel before the click can land on one — the dropdown appears to
|
|
23
|
+
ignore every selection.
|
|
24
|
+
|
|
25
|
+
```jsx
|
|
26
|
+
function onDown(e) {
|
|
27
|
+
var root = rootRef.current;
|
|
28
|
+
if (!root) return;
|
|
29
|
+
var path = e.composedPath ? e.composedPath() : [];
|
|
30
|
+
for (var i = 0; i < path.length; i++) {
|
|
31
|
+
if (path[i] === root) return; // inside — leave it open
|
|
32
|
+
}
|
|
33
|
+
if (path.length === 0 && e.target && root.contains(e.target)) return; // fallback
|
|
34
|
+
setOpen(false);
|
|
35
|
+
}
|
|
36
|
+
document.addEventListener("mousedown", onDown, true);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**2. Define it at MODULE scope, never inside `Block()`.**
|
|
40
|
+
A component redefined inside `Block()` gets a fresh identity every render, so React unmounts
|
|
41
|
+
and remounts the `<input>` and the search box loses focus after one keystroke. This is the
|
|
42
|
+
single most common Vibe Coding bug and it looks like a platform fault.
|
|
43
|
+
|
|
44
|
+
**3. `onMouseDown` on a row must `preventDefault()`.**
|
|
45
|
+
Otherwise focus leaves the search input before the click resolves.
|
|
46
|
+
|
|
47
|
+
## Sort A→Z *inside* the component
|
|
48
|
+
|
|
49
|
+
Sorting at the call site gets forgotten. Do it in the component, with an opt-out:
|
|
50
|
+
|
|
51
|
+
```jsx
|
|
52
|
+
function comboSortLabels(a, b) {
|
|
53
|
+
return String(a.label || "").localeCompare(String(b.label || ""), undefined, {
|
|
54
|
+
sensitivity: "base", // case-insensitive
|
|
55
|
+
numeric: true, // "Item 2" before "Item 10" — a plain compare gets this backwards
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`autoSort` defaults to **true**. Pass `autoSort={false}` only where the given order *is* the
|
|
61
|
+
meaning — a pipeline of statuses (Not ordered → Ordered → … → Delivered), a physical
|
|
62
|
+
journey, a curated short list. Alphabetising a workflow puts "Damaged" first and buries the
|
|
63
|
+
starting state in the middle, which is worse than not sorting at all.
|
|
64
|
+
|
|
65
|
+
Grouped lists sort **within** a group and keep first-seen group order, so a "this project's
|
|
66
|
+
rooms first, everyone else after" grouping survives being alphabetised.
|
|
67
|
+
|
|
68
|
+
## Filter on every token, in any order
|
|
69
|
+
|
|
70
|
+
```jsx
|
|
71
|
+
var tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
72
|
+
var filtered = options.filter(function (o) {
|
|
73
|
+
if (tokens.length === 0) return true;
|
|
74
|
+
var hay = String(o.label || "").toLowerCase();
|
|
75
|
+
for (var i = 0; i < tokens.length; i++) {
|
|
76
|
+
if (hay.indexOf(tokens[i]) === -1) return false;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
So `rural art` finds *The Rural Art Company*. A single `indexOf(query)` would not.
|
|
83
|
+
|
|
84
|
+
## Show the search box only when it earns its place
|
|
85
|
+
|
|
86
|
+
```jsx
|
|
87
|
+
var searchable = props.onCreate
|
|
88
|
+
? true
|
|
89
|
+
: props.searchable === true || (props.searchable !== false && options.length >= 8);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Under eight options a search box is noise — the picker should still read as a picker.
|
|
93
|
+
|
|
94
|
+
## One flat row list for the keyboard
|
|
95
|
+
|
|
96
|
+
Build `rows` as a single array — the optional *clear* row, then the filtered options, then
|
|
97
|
+
the optional *create* row — so arrow-key navigation has one index to walk. Clamp the active
|
|
98
|
+
index (`Math.min(active, rows.length - 1)`): filtering shrinks the list under the highlight.
|
|
99
|
+
|
|
100
|
+
## Drop up near the fold
|
|
101
|
+
|
|
102
|
+
```jsx
|
|
103
|
+
var r = rootRef.current.getBoundingClientRect();
|
|
104
|
+
var below = window.innerHeight - r.bottom;
|
|
105
|
+
setDropUp(below < 300 && r.top > below);
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A filter row near the bottom of the viewport otherwise opens into nothing.
|
|
109
|
+
|
|
110
|
+
## Variants worth having
|
|
111
|
+
|
|
112
|
+
- **`bare`** — inline-editor mode. No border, no fill; `triggerContent` (a status chip, a
|
|
113
|
+
cell's text) *is* the trigger. Lets a table cell become editable without every row growing
|
|
114
|
+
a form control.
|
|
115
|
+
⚠ If any column width in your table is derived from the trigger's chrome, keep the
|
|
116
|
+
chevron the SAME size in both variants. Shrinking it in `bare` silently changes those
|
|
117
|
+
widths in a different file.
|
|
118
|
+
- **`triggerStyle`** — merged over the defaults, for a trigger that is part of the design
|
|
119
|
+
(a chip painted in its own status colour) rather than a plain field.
|
|
120
|
+
- **`onCreate(text)`** — offers `Add "<typed>"` when nothing matches. If creating the record
|
|
121
|
+
and *linking* it happen at different times, say so in the toast: the two halves landing
|
|
122
|
+
separately is exactly what gets reported as "it didn't save".
|
|
123
|
+
- **`emptyLabel`** — a row that clears the selection. Call sites that pass a leading
|
|
124
|
+
`{ value: "" }` "any / none" option should have it lifted into `emptyLabel` rather than
|
|
125
|
+
rendered twice.
|
|
126
|
+
|
|
127
|
+
## The one shadow you are allowed
|
|
128
|
+
|
|
129
|
+
DESIGN-system rules that ban shadows are about cards. A white panel floating over a white
|
|
130
|
+
card with only a hairline between them reads as part of the card, so the menu gets a shadow:
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
boxShadow: "0 10px 30px rgba(0, 0, 0, 0.16)"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Everything else — the trigger, the card, the rows — stays flat.
|
|
137
|
+
|
|
138
|
+
## Checklist before shipping a dropdown
|
|
139
|
+
|
|
140
|
+
- [ ] Defined at module scope
|
|
141
|
+
- [ ] `composedPath()` click-outside
|
|
142
|
+
- [ ] Sorted A→Z inside the component, with `autoSort={false}` only where order is meaning
|
|
143
|
+
- [ ] Multi-token filter
|
|
144
|
+
- [ ] Search box hidden under 8 options
|
|
145
|
+
- [ ] Keyboard: ↑ ↓ Enter Esc Tab, active row scrolled into view
|
|
146
|
+
- [ ] `aria-haspopup="listbox"`, `aria-expanded`, `role="listbox"` / `role="option"`,
|
|
147
|
+
`aria-selected`, and an `aria-label` on the trigger
|
|
148
|
+
- [ ] Loading and empty states (`"Nothing matches that."`)
|
|
149
|
+
- [ ] No `@/components/ui/select` import anywhere in the file
|
package/ui-ux-guidelines.md
CHANGED
|
@@ -719,6 +719,8 @@ Actively check for and reject these fingerprints of generic AI-generated interfa
|
|
|
719
719
|
- **Relative timestamps:** "2 hours ago" via `date-fns/formatDistanceToNow`
|
|
720
720
|
- **Truncate long text** with `truncate` or `line-clamp-2`, full value in `Tooltip`
|
|
721
721
|
- **Sticky headers** for long tables
|
|
722
|
+
- **Never render an affordance you have not wired.** A grip glyph that does not drag, a chevron that does not sort, a card that looks clickable and is not — the signifier IS the promise, and an unfulfilled one reads as a broken feature, not a missing one. Either wire it or delete it. (Observed 2026-09-09: a `GripVertical` shipped as decoration on every row of a reorderable list; users reported the list as "can't be reordered", not as "missing drag".)
|
|
723
|
+
- **A control that is disabled by default is indistinguishable from a broken one.** If the only explanation lives in a `title` tooltip, nobody reads it — they file a bug. When a control depends on a mode the user has not chosen yet, prefer making the action *switch the mode and proceed* over greying it out. Disable only for genuine impossibility (permissions, first row can't move up), and when you do, say why in visible text rather than on hover. (Same 2026-09-09 report: reorder arrows were disabled until you switched the sort to Manual, which nothing on screen told you.)
|
|
722
724
|
|
|
723
725
|
---
|
|
724
726
|
|