The id-kind value cells (feed/library/collection/added_by) rendered their picks as one comma-joined string inside a 9rem-wide input — you saw the first label and nothing else, and reading 3-5 picks meant scrolling inside a tiny box (worst on mobile, user request 2026-11-01). Id-kind cells now render a chipbox: each picked value is a chip (label truncated with a hover title, removable via an X button with a finger- sized tap target), the box flex-wraps and grows vertically instead of clipping, and the inline input stays at the end for typing/searching — including the Backspace-on-empty-removes-last-chip affordance. Text/ number/date cells keep today's single input; the value menu is unchanged (it never offers an already-picked value, so the chip X is the faster removal path, not a new one). Presentation only: the stored wire is still one comma-joined value per rule; matching, menus, apply, and label resolution are untouched. Pins moved from valueCellText (now text-cells-only) to the chip model (idChipsFor/idDraftFor) and cover removal order, last-chip removal, and Backspace; 187 web tests green.
122 lines
8.8 KiB
Templ
122 lines
8.8 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" :class="isIdValueRow(row.path) ? 'fb-chipbox flex-wrap' : ''">
|
||
<template x-if="isIdValueRow(row.path)">
|
||
<span class="contents">
|
||
<!-- picked values as removable chips: the box
|
||
wraps and grows instead of a 9rem input
|
||
you can't read past the first entry -->
|
||
<template x-for="chip in idChipsFor(row.path)" :key="chip.id">
|
||
<span class="fb-chip" :title="chip.label">
|
||
<span class="fb-chip-label" x-text="chip.label"></span>
|
||
<button type="button" class="fb-chip-x" :aria-label="'Remove ' + chip.label" @click="removeIdChip(row.path, chip.id)">×</button>
|
||
</span>
|
||
</template>
|
||
<input type="text" class="fb-value fb-chip-input" :value="idDraftFor(row.path)" @input.debounce.250ms="onValueCellInput(row.path, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" @keydown="chipInputKeydown(row.path, $event)" :placeholder="valuePlaceholderFor(row.rule)" :aria-label="fieldLabelOf(row.rule.field) + ' values'"/>
|
||
</span>
|
||
</template>
|
||
<template x-if="!isIdValueRow(row.path)">
|
||
<span class="contents">
|
||
<input type="text" class="input fb-value" :value="valueCellText(row.path)" @input.debounce.250ms="onValueCellInput(row.path, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="valuePlaceholderFor(row.rule)"/>
|
||
<template x-if="fieldIsDays(row.rule.field)">
|
||
<span class="text-xs whitespace-nowrap" style="color: var(--text-secondary)" x-text="'days'"></span>
|
||
</template>
|
||
</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>
|
||
}
|