docs(dev): alpine-patterns rewritten around the CSP build
The March-era page had the right instincts (store state across swaps, no manual DOM manipulation) but predated the @alpinejs/csp conversion and never said the load-bearing fact: inline expressions are not evaluated, so every x-data must reference an Alpine.data component — which is why the whole attribute value is an expression and why dynamic arguments go through JSONArg or data-attributes. Rewritten with the banned list spelled out (inline handlers, hx-on*, js: hx-vals, stray inline <script>) and each rule tied to the source that enforces or embodies it (alpine.ts import, header.templ whole-expression x-data, custom-section-builder data-attribute pattern, bookPicker store as the living example). Every code sample verified present in web/src.
This commit is contained in:
1 parent
4bd2788c7d
commit
35b210951a
1 file changed
+90
-79
@@ -1,92 +1,103 @@
|
||||
# Alpine.js Patterns in Bookhoard
|
||||
## Book Picker Modal Pattern (SSR + HTMX + Alpine.store)
|
||||
For modals that need state persistence across HTMX swaps:
|
||||
### TypeScript
|
||||
<!-- Ground truth: web/src/alpine.ts (the @alpinejs/csp build — this is
|
||||
load-bearing for every rule below), component registrations across
|
||||
web/src (Alpine.data in admin.ts, api.ts, audio.ts, book-detail.ts,
|
||||
bookPicker.ts), the bookPicker store (web/src/bookPicker.ts), the
|
||||
dynamic-attribute patterns in templates/header.templ
|
||||
(x-data="sidebarShell('…')" whole-expression values) and
|
||||
web/src/custom-section-builder.ts (data-attributes instead of inline
|
||||
handlers — the CSP build refuses them). Re-verify when the frontend
|
||||
stack changes. -->
|
||||
|
||||
# Alpine.js patterns in Bookhoard
|
||||
|
||||
Bookhoard's Alpine runs the **`@alpinejs/csp` build** — the CSP build, so
|
||||
the app's `script-src` can stay a hash allow-list with no
|
||||
`'unsafe-eval'`. That single fact dictates most of this page: inline
|
||||
JavaScript in HTML is not evaluated, so everything expressive lives in
|
||||
registered TypeScript components.
|
||||
|
||||
## Rule one: components are registered, not inlined
|
||||
|
||||
Every `x-data` references a component registered with `Alpine.data` in
|
||||
`web/src` — the attribute value is the component name (optionally with
|
||||
arguments), never an object literal:
|
||||
|
||||
```typescript
|
||||
// web/src/admin.ts
|
||||
Alpine.data("admin", () => ({ /* state + methods */ }));
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- templates/admin.templ -->
|
||||
<body x-data="admin" class="theme-tokyo-night">
|
||||
```
|
||||
|
||||
Arguments work, but the **whole attribute value must be the expression**
|
||||
— templ does not interpolate `{ expr }` inside quoted attribute values,
|
||||
so `x-data="sidebarShell('{ col.ID }')"` would ship a literal `{ col.ID }`
|
||||
to the browser:
|
||||
|
||||
```html
|
||||
<div x-data={ "sidebarShell('" + string(mode) + "')" }>
|
||||
```
|
||||
|
||||
Free-text arguments that could contain quotes go through
|
||||
`templates.JSONArg`, never string concatenation.
|
||||
|
||||
## Stores: state that survives htmx swaps
|
||||
|
||||
Alpine's reactivity is DOM-local; when htmx swaps a node, its `x-data`
|
||||
state dies with it. State that must survive swaps lives in a store —
|
||||
`web/src/bookPicker.ts` is the canonical example:
|
||||
|
||||
```typescript
|
||||
// web/src/bookPicker.ts
|
||||
Alpine.store("bookPicker", {
|
||||
isOpen: false,
|
||||
selectedBooks: new Set<string>(),
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.selectedBooks.clear();
|
||||
this.loadBooks();
|
||||
},
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.selectedBooks.clear();
|
||||
},
|
||||
|
||||
toggleBook(id: string) {
|
||||
if (this.selectedBooks.has(id)) {
|
||||
this.selectedBooks.delete(id);
|
||||
} else {
|
||||
this.selectedBooks.add(id);
|
||||
}
|
||||
// Trigger reactivity
|
||||
this.selectedBooks = new Set(this.selectedBooks);
|
||||
},
|
||||
|
||||
isSelected(id: string): boolean {
|
||||
return this.selectedBooks.has(id);
|
||||
},
|
||||
|
||||
get selectedCount(): number {
|
||||
return this.selectedBooks.size;
|
||||
}
|
||||
open() { /* … */ },
|
||||
toggleBook(id: string) { /* … */ },
|
||||
});
|
||||
Template
|
||||
<!-- Open button -->
|
||||
```
|
||||
|
||||
```html
|
||||
<button @click="$store.bookPicker.open()">Add Books</button>
|
||||
<!-- Modal -->
|
||||
<div x-show="$store.bookPicker.isOpen"
|
||||
x-transition
|
||||
@click.self="$store.bookPicker.close()"
|
||||
style="display: none;">
|
||||
<span x-text="$store.bookPicker.selectedBooks.size"></span>
|
||||
```
|
||||
|
||||
<!-- Search with HTMX -->
|
||||
<input @input.debounce.300ms="searchBooks($el.value)"
|
||||
hx-get="/api/media-items/filtered"
|
||||
hx-target="#book-picker-grid"
|
||||
hx-include="#filter-form" />
|
||||
The server returns HTML (checked checkboxes included); the store tracks
|
||||
selection across swaps; nobody rebuilds markup from JSON.
|
||||
|
||||
<!-- Book grid with checkboxes -->
|
||||
<div id="book-picker-grid" x-init="loadBooks()">
|
||||
<!-- Server returns HTML with checkboxes -->
|
||||
</div>
|
||||
## Local state: plain `x-data` on the smallest element
|
||||
|
||||
<!-- Selected count -->
|
||||
<span x-text="$store.bookPicker.selectedCount"></span>
|
||||
Ephemeral UI (a dropdown, a panel toggle) needs no store and no
|
||||
round-trip:
|
||||
|
||||
<!-- Submit -->
|
||||
<button @click="$store.bookPicker.submit()">Add Selected</button>
|
||||
</div>
|
||||
Key Principles
|
||||
- ✅ Alpine.store persists state across HTMX DOM swaps
|
||||
- ✅ Server returns HTML with checkbox :checked attributes
|
||||
- ✅ No client-side rendering or manual DOM manipulation
|
||||
- ✅ HTMX handles dynamic content updates
|
||||
- ✅ Alpine manages UI state only
|
||||
Dropdown Pattern (Local x-data)
|
||||
```html
|
||||
<div x-data="{ dropdownOpen: false }">
|
||||
<button @click="dropdownOpen = !dropdownOpen">Toggle</button>
|
||||
<div x-show="dropdownOpen"
|
||||
@click.outside="dropdownOpen = false"
|
||||
x-transition
|
||||
style="display: none;">
|
||||
<!-- Dropdown content -->
|
||||
</div>
|
||||
<div x-show="dropdownOpen" @click.outside="dropdownOpen = false">…</div>
|
||||
</div>
|
||||
Best Practices
|
||||
- ✅ State lives in template (x-data)
|
||||
- ✅ UI updates automatically (x-show)
|
||||
- ✅ No manual DOM manipulation in TypeScript
|
||||
- ✅ Pure business logic only in TypeScript functions
|
||||
- ✅ Use Alpine.store for global state
|
||||
- ✅ Use local x-data for component state
|
||||
- ✅ Use x-init for setup only (no data fetch for SSR pages)
|
||||
- ❌ Never use classList in TypeScript
|
||||
- ❌ Never use getElementById for show/hide
|
||||
- ❌ Never fetch data in x-init (for SSR pages)
|
||||
```
|
||||
|
||||
## The banned list — all enforced by the CSP build
|
||||
|
||||
- **No inline handlers**, in any form: `onclick=`, `onerror=`,
|
||||
templ-brace variants — hash-mode CSP refuses them unconditionally.
|
||||
Rows generated in TypeScript carry `data-*` attributes and bind
|
||||
listeners after swap (see `custom-section-builder.ts`).
|
||||
- **No `hx-on*` attributes and no `js:`-prefixed `hx-vals`** — both
|
||||
evaluate strings, which the CSP build cannot do. Plain JSON `hx-vals`
|
||||
only (`templates/utils.go` has the helper).
|
||||
- **No inline `<script>` outside `theme_flash_guard.templ`** (the one
|
||||
sanctioned pre-paint script); everything else bundles from `web/src`.
|
||||
|
||||
## Division of labor
|
||||
|
||||
The full ownership rule lives in
|
||||
[Writing UI documentation without screenshots](writing-ui-docs.md) and
|
||||
the repo's AGENTS.md: **templ decides what things look like; TypeScript
|
||||
decides what shows.** Content derived from server data is rendered by
|
||||
templ (fetch a partial with `htmx.ajax` when it changes); TypeScript
|
||||
handles pure client-local state — open/closed panels, validation,
|
||||
toasts. `classList`/`getElementById` show-hide from TypeScript is a
|
||||
defect, not a shortcut.
|
||||
Reference in new issue
Block a user