Every one of these was silent at the code level and loud in a real
browser; each fix carries its own tripwire (render pin, unit pin, or
CSP-safe rewrite) so the class cannot quietly return.
1. Theme picker dead since the CSP migration (eb57b720): the Appearance
panel's theme buttons emitted `@click="selectTheme(opt.Name)"` as
LITERAL text (templ gotcha #1 — quoted attribute values are never
interpolated; `opt` is a Go loop var), so Alpine threw "Undefined
variable: opt" on every click and no PUT /api/auth/theme ever fired.
The wood-paneling sibling always used the correct expression form,
which is why the panel half-worked. Fixed to the interpolated form;
TestThemePickerHandlersAreInterpolated pins that real theme names
reach the HTML and the raw identifier never does.
2. Filter-builder op dropdown "does nothing": the field ingest
hardcoded `ops: []` into every field's meta while only mirroring the
ops into the global label registry — opsFor() returned [] for EVERY
field, so the op menu rendered zero items: an invisible menu, no
console error (user tried author/publisher/title/subtitle, all
equally dead). FieldMeta.ops is now the wire's op objects, opsFor()
is a standalone exported helper, and opsForField unit tests pin the
round-trip. Verified live: the field menu opens/closes while the op
menu stayed shut, both with identical wiring — the difference was
the data, not the wiring.
3. AND/OR connective chip dead: the chip carried an `if` STATEMENT in
the Alpine attribute (`@click="if (row.chipClickable) ..."`) which
the CSP build's evaluator rejects as a statement — the whole
expression silently never ran. Moved the clickability check into a
toggleConnective(row) component method; the attribute is now a plain
method call.
4. Pencil vs card navigation race: navigateToCollection guarded with
`event.target instanceof HTMLElement`, which is FALSE for the
pencil glyph's SVGElement — a click landing on the glyph sailed past
the guard and navigated while the htmx edit-modal request still
fired (the reported "modal flashes, then the builder page"). The
guard now uses target.closest("button") (works on SVGElement) and
takes $event explicitly instead of the deprecated window.event.
.icon-btn is already a 36px square, so no hit-area change needed.
5. Add Items modal under the sidebar + taller than the window: the
picker overlay sat at z-50 UNDER the app-sidebar's z-60 (its left
edge rendered beneath the nav), and the card had no height bound —
header + tabs + filters + grid + footer overflow short windows with
the footer unreachable (fixed overlay, no page scroll). The picker
and create-modal overlays move to z-[65], both confirm dialogs to
z-[70], and the picker card is now a flex column capped at
max-h-[85vh] whose grids flex-fill (they grow on tall windows,
shrink on short ones). The ladder is documented at .app-sidebar in
input.css: sidebar 60 / overlays 65 / confirm 70 / toasts 9999.
Found by clicking every control the way a user does (panel clicks +
computed-style reads + screenshots) after the previous sweep trusted
DOM-text snapshots, which show hidden menus' text and cannot see
occlusion, stacking, or geometry at all.
104 lines
7.6 KiB
Templ
104 lines
7.6 KiB
Templ
package templates
|
||
|
||
// filterBuilderRows is THE shared builder rows markup (item 3.3/3.4):
|
||
// one presentation for every builder mount — All-Books and collection
|
||
// smart editors. Purely presentational: every method/property lives on
|
||
// the shared filterBuilderExtra Alpine component (web/src/
|
||
// filter_builder.ts), whose vocabulary arrives from
|
||
// services.FilterFieldsPayload(scope).
|
||
templ FilterBuilderRows(countNoun string) {
|
||
<template x-for="row in builderRows" :key="row.key">
|
||
<div>
|
||
<template x-if="row.type === 'groupOpen'">
|
||
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-1" :style="'margin-left:' + (row.depth * 14) + 'px'">
|
||
<span class="text-[11px] font-semibold uppercase tracking-wide" style="color: var(--text-secondary)" x-text="row.depth === 0 ? 'Match' : 'Group'"></span>
|
||
<span class="flex-1"></span>
|
||
<button type="button" class="fb-mini-btn" @click="addRule(row.path)">+ rule</button>
|
||
<button type="button" class="fb-mini-btn" @click="addGroup(row.path)">+ group</button>
|
||
<button type="button" class="fb-mini-btn fb-danger" @click="removeNode(row.path)" aria-label="Remove group">×</button>
|
||
</div>
|
||
</template>
|
||
<template x-if="row.type === 'rule'">
|
||
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-0.5" :style="'margin-left:' + ((row.depth + 1) * 14) + 'px'">
|
||
<button type="button" class="fb-connective fb-connective-btn" :class="row.chipClickable ? '' : 'fb-chip-static'" x-show="!row.isFirst" x-text="row.chip" @click="toggleConnective(row)" :title="row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip"></button>
|
||
<!-- field picker: grouped menu -->
|
||
<div class="relative">
|
||
<button type="button" class="fb-picker" :class="{'fb-picker-active': row.rule.field !== ''}" @click="toggleMenu(row.key + ':field')" x-text="fieldLabelOf(row.rule.field)"></button>
|
||
<div class="fb-menu fb-menu-fields" x-show="openMenu === row.key + ':field'" @click.outside="openMenu = ''">
|
||
<div class="fb-menu-filter-wrap">
|
||
<input type="text" class="fb-menu-filter" placeholder="Filter fields…" x-model="fieldFilter" @keydown.enter.prevent @click.stop/>
|
||
</div>
|
||
<div class="fb-menu-scroll">
|
||
<template x-for="grp in visibleFieldGroups" :key="grp.group">
|
||
<div>
|
||
<div class="fb-menu-group" x-text="grp.group"></div>
|
||
<template x-for="f in grp.fields" :key="f.key">
|
||
<button type="button" class="fb-menu-item" :class="row.rule.field === f.key ? 'fb-menu-item-active' : ''" @click="setRuleField(row.path, f.key)" x-text="f.label"></button>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<div class="fb-menu-empty" x-show="visibleFieldGroups.length === 0">No matching fields</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- operator picker (negation lives here) -->
|
||
<div class="relative">
|
||
<button type="button" class="fb-picker fb-picker-op" :class="{'fb-negated-text': isNegated(row.rule)}" @click="toggleMenu(row.key + ':op')" x-text="opLabelOf(row.rule)"></button>
|
||
<div class="fb-menu" x-show="openMenu === row.key + ':op'" @click.outside="openMenu = ''">
|
||
<template x-for="o in opsFor(row.rule.field)" :key="o.key">
|
||
<button type="button" class="fb-menu-item" :class="[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']" @click="setRuleOp(row.path, o.key)" x-text="o.label"></button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<!-- value cell: shape follows the operator + field kind -->
|
||
<template x-if="hasValueCell(row.rule) && valueArity(row.rule) === 'two'">
|
||
<span class="flex items-center gap-1">
|
||
<input type="text" class="input fb-value" :value="row.rule.values[0] || ''" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" @click.outside="suggestionsOpen = ''" placeholder="From"/>
|
||
<span class="text-xs" style="color: var(--text-secondary)">and</span>
|
||
<input type="text" class="input fb-value" :value="row.rule.values[1] || ''" @input.debounce.250ms="setRuleValue(row.path, 1, $el.value)" placeholder="To"/>
|
||
</span>
|
||
</template>
|
||
<template x-if="hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')">
|
||
<span class="fb-value-cell relative flex items-center gap-1">
|
||
<input type="text" class="input fb-value" :value="row.rule.values.join(',')" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="fieldIsDays(row.rule.field) ? 'days' : 'value'"/>
|
||
<template x-if="fieldIsDays(row.rule.field)">
|
||
<span class="text-xs whitespace-nowrap" style="color: var(--text-secondary)" x-text="'days'"></span>
|
||
</template>
|
||
<!-- field-aware value menu: server JSON (the other
|
||
rules bound), or the field's fixed options.
|
||
Clicks anywhere inside the value cell never close
|
||
it — the opening focus-click must not race the
|
||
outside handler. -->
|
||
<div class="fb-menu fb-menu-value" x-show="suggestionShown(row.key)" @click.outside="dismissSuggestions($event)">
|
||
<template x-for="s in (suggestions[row.key] || [])" :key="s.value">
|
||
<button type="button" class="fb-menu-item" @click="pickSuggestion(row.key, row.path, s)">
|
||
<span x-text="s.display || s.value"></span>
|
||
<span class="text-xs" style="color: var(--text-secondary)" x-show="s.count > 0" x-text="s.count + ' books'"></span>
|
||
</button>
|
||
</template>
|
||
<div class="fb-menu-empty" x-show="suggestionsLoading === row.key">Loading…</div>
|
||
<div class="fb-menu-empty" x-show="suggestionsLoading !== row.key && !(suggestions[row.key] || []).length">No matching values</div>
|
||
</div>
|
||
</span>
|
||
</template>
|
||
<!-- delete rule -->
|
||
<button type="button" class="fb-mini-btn fb-danger" @click="removeNode(row.path)" aria-label="Remove rule">×</button>
|
||
</div>
|
||
</template>
|
||
<template x-if="row.type === 'groupClose' && row.depth > 0">
|
||
<div class="fb-group-end" :style="'margin-left:' + (row.depth * 14) + 'px'"></div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<!-- footer: add rule/group + the debounced live count
|
||
(the composed engine's true COUNT — what the surface will
|
||
show once applied) -->
|
||
<div class="flex items-center justify-between px-1 py-2">
|
||
<div class="flex items-center gap-1.5">
|
||
<button type="button" class="fb-mini-btn" @click="addRule([])">+ Rule</button>
|
||
<button type="button" class="fb-mini-btn" @click="addGroup([])">+ Group</button>
|
||
</div>
|
||
<span class="text-xs" style="color: var(--text-secondary)" x-show="matchCount !== null" x-text={ "matchCountLoading ? 'counting…' : (matchCount === 1 ? '1 " + countNoun + " matches' : matchCount + ' " + countNoun + "s match')" }></span>
|
||
</div>
|
||
}
|