1<template>
2 <Popover ref="popover" bare side="bottom" align="end">
3 <template #trigger>
4 <div class="w-fit">
5 <FilterTrigger
6 :count="activeFilters.length"
7 @toggle="resetSteps()"
8 @clear="clearAllAndClose"
9 />
10 </div>
11 </template>
12 <template #default>
13 <!-- Dedicated high-z layer the operator dropdown teleports into. Both this
14 and the frappe-ui Popover panel land in <body>; the popover panel is
15 z-[100], so a body-mounted menu (z-auto) renders behind it. This layer
16 establishes a z-[101] stacking context above the panel, and owning it
17 here keeps the fix scoped to the filter. -->
18 <Teleport to="body">
19 <div ref="operatorMenuLayer" class="relative z-[101]" />
20 </Teleport>
21 <div
22 class="my-2 w-80 rounded-6 border border-outline-gray-1 bg-surface-base shadow-xl"
23 >
24 <div class="relative overflow-clip rounded-[inherit]">
25 <Transition
26 :name="direction === 'forward' ? 'slide-forward' : 'slide-back'"
27 >
28 <div :key="step">
29 <!-- Step: overview — the list of active filters -->
30 <template v-if="step === 'overview'">
31 <div
32 ref="overviewHeader"
33 tabindex="-1"
34 class="flex items-center border-b border-outline-gray-1 p-2 ps-3 h-9 outline-none"
35 @vue:mounted="focusOverview"
36 >
37 <span class="text-base-medium text-ink-gray-8">
38 {{ headerLabel }}
39 </span>
40 </div>
41 <div>
42 <div class="p-1.5">
43 <div
44 v-for="filter in activeFilters"
45 :key="filter.index"
46 class="group flex h-8 w-full items-center gap-2 rounded-4 px-1.5 hover:bg-surface-gray-2 pl-0"
47 >
48 <Button
49 variant="ghost"
50 :label="filterSummary(filter)"
51 :tooltip="filterSummary(filter)"
52 class="!h-full min-w-0 flex-1 !justify-start !px-0 hover:!bg-transparent !pl-1.5"
53 @click="editFilter(filter)"
54 >
55 <template #prefix>
56 <component
57 :is="fieldIcon(filter.field)"
58 class="size-4 shrink-0 text-ink-gray-5"
59 />
60 </template>
61 </Button>
62 <div class="flex shrink-0 items-center gap-1">
63 <Button
64 variant="ghost"
65 icon="lucide-arrow-left-right"
66 :aria-label="__('Replace filter')"
67 :tooltip="__('Replace filter')"
68 :class="revealOnRowActivity"
69 @click="replaceFilter(filter)"
70 />
71 <Button
72 variant="ghost"
73 icon="lucide-x"
74 :aria-label="__('Remove filter')"
75 :tooltip="__('Remove filter')"
76 :class="revealOnRowActivity"
77 @click="removeFilter(filter.index)"
78 />
79 </div>
80 </div>
81 </div>
82 <div
83 class="flex items-center justify-between border-t border-outline-gray-1 px-1 py-1.5"
84 >
85 <Button
86 variant="ghost"
87 :size="'xs'"
88 class="!text-ink-gray-5"
89 :label="__('Add filter')"
90 @click="addNewFilter()"
91 tooltip="Add Filter (A)"
92 >
93 <template #prefix>
94 <LucidePlus class="size-4" />
95 </template>
96 </Button>
97 <Button
98 :size="'xs'"
99 variant="ghost"
100 class="!text-ink-gray-5"
101 :label="__('Clear all')"
102 :icon-left="'lucide-trash-2'"
103 @click="clearFilters()"
104 />
105 </div>
106 </div>
107 </template>
108
109 <!-- Step: fields — choose which field to filter on -->
110 <template v-else-if="step === 'fields'">
111 <div
112 v-if="canGoBack"
113 class="flex h-9 items-center border-b border-outline-gray-1 ps-1"
114 >
115 <BackButton :label="headerLabel" size="sm" @back="goBack" />
116 </div>
117 <FilterFieldList
118 :fields="fields"
119 :placeholder="
120 canGoBack ? __('Search fields...') : __('Add Filter...')
121 "
122 :show-shortcut-hint="!activeFilters.length"
123 @select="selectField"
124 @back="goBack"
125 />
126 </template>
127
128 <!-- Step: value — pick operator + value (renders its own header) -->
129 <FilterValueEditor
130 v-else-if="step === 'value' && selectedField"
131 ref="valueEditor"
132 :key="editSession"
133 :field="selectedField"
134 :filter="editingFilter"
135 :operator-menu-target="operatorMenuLayer"
136 @apply="applyFilter"
137 @clear="clearCurrentFilter"
138 @back="goBack"
139 @done="step = 'overview'"
140 />
141 </div>
142 </Transition>
143 </div>
144 </div>
145 </template>
146 </Popover>
147</template>
148
149<script setup lang="ts">
150import BackButton from "./BackButton.vue";
151import { useShortcut } from "../shortcuts";
152import { __ } from "../translation";
153import { useEventListener } from "@vueuse/core";
154import { Button, Popover } from "frappe-ui";
155import { computed, nextTick, ref, watch } from "vue";
156import {
157 ActiveFilter,
158 fieldIcon,
159 FilterField,
160 filterSummary,
161 useFilter,
162} from "./filter";
163import FilterFieldList from "./FilterFieldList.vue";
164import FilterTrigger from "./FilterTrigger.vue";
165import FilterValueEditor from "./FilterValueEditor.vue";
166
167const {
168 fields,
169 activeFilters,
170 addFilter,
171 updateFilter,
172 removeFilter,
173 clearFilters,
174} = useFilter();
175
176const step = ref<"overview" | "fields" | "value">("fields");
177const selectedField = ref<FilterField | null>(null);
178const editingIndex = ref<number | null>(null);
179// The row a "Replace" is targeting. The old condition is kept untouched until a
180// new one is chosen, so backing out leaves the original filter intact; the
181// replacement then lands at this same position.
182const replacingIndex = ref<number | null>(null);
183const editSession = ref(0);
184// Where the value editor was opened from, so "back" returns there: the field
185// list (when adding a new filter) or the overview (when editing an existing one).
186const valueEditorOrigin = ref<"overview" | "fields">("overview");
187const overviewHeader = ref<HTMLElement | null>(null);
188// The high-z layer the operator dropdown teleports into (see #body), so its menu
189// sits above the filter popover panel instead of behind it.
190const operatorMenuLayer = ref<HTMLElement | null>(null);
191const popover = ref<InstanceType<typeof Popover> | null>(null);
192const valueEditor = ref<InstanceType<typeof FilterValueEditor> | null>(null);
193
194// Row actions stay out of the way until the row is hovered or focused.
195const revealOnRowActivity =
196 "opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100";
197
198// used for animating back or forward between steps
199const stepOrder = { overview: 0, fields: 1, value: 2 } as const;
200const direction = ref<"forward" | "back">("forward");
201
202const headerLabel = computed(() => {
203 if (step.value === "overview") return __("Filters");
204 if (step.value === "fields") return __("Choose a field");
205 return selectedField.value?.label || "";
206});
207
208const canGoBack = computed(() => {
209 if (step.value === "value") return true;
210 return step.value === "fields" && activeFilters.value.length > 0;
211});
212
213const editingFilter = computed<ActiveFilter | null>(() => {
214 if (editingIndex.value === null) return null;
215 return (
216 activeFilters.value.find((f) => f.index === editingIndex.value) || null
217 );
218});
219
220useShortcut("f", () => {
221 resetSteps();
222 popover.value?.open();
223});
224
225// The popover auto-focuses its first focusable element on open, which would put
226// a focus ring on the first filter row. Pull focus to the (non-tabbable) header
227// instead so the overview opens without a row looking selected.
228function focusOverview() {
229 nextTick(() => overviewHeader.value?.focus({ preventScroll: true }));
230}
231
232function resetSteps() {
233 step.value = activeFilters.value.length ? "overview" : "fields";
234 selectedField.value = null;
235 editingIndex.value = null;
236 replacingIndex.value = null;
237}
238
239// Opens the field list to add a fresh filter, cancelling any pending replace.
240function addNewFilter() {
241 replacingIndex.value = null;
242 step.value = "fields";
243}
244
245function selectField(field: FilterField) {
246 selectedField.value = field;
247 editingIndex.value = null;
248 valueEditorOrigin.value = "fields";
249 editSession.value++;
250 step.value = "value";
251}
252
253// "Replace" jumps to the field list to pick a different field, but keeps the
254// existing condition in place — it's only swapped out once the new one is
255// applied (see applyFilter), so abandoning the flow loses nothing.
256function replaceFilter(filter: ActiveFilter) {
257 replacingIndex.value = filter.index;
258 step.value = "fields";
259}
260
261function editFilter(filter: ActiveFilter) {
262 selectedField.value = filter.field;
263 editingIndex.value = filter.index;
264 replacingIndex.value = null;
265 valueEditorOrigin.value = "overview";
266 editSession.value++;
267 step.value = "value";
268}
269
270function applyFilter(operator: string, value: any) {
271 if (!selectedField.value) return;
272 // A pending replace overwrites its target row in place; otherwise we update
273 // the row being edited, or append a brand-new filter.
274 const targetIndex = replacingIndex.value ?? editingIndex.value;
275 if (targetIndex === null) {
276 editingIndex.value = addFilter(selectedField.value, operator, value);
277 } else {
278 updateFilter(targetIndex, selectedField.value, operator, value);
279 editingIndex.value = targetIndex;
280 }
281 replacingIndex.value = null;
282}
283
284function clearAllAndClose() {
285 valueEditor.value?.discardPendingText();
286 clearFilters();
287 popover.value?.close();
288}
289
290function clearCurrentFilter() {
291 if (editingIndex.value === null) return;
292 removeFilter(editingIndex.value);
293 editingIndex.value = null;
294}
295
296// "Back" returns to wherever the current step was opened from: the value editor
297// to its origin (field list or overview), and the field list to the overview.
298function goBack() {
299 if (step.value === "value") {
300 step.value = valueEditorOrigin.value;
301 return;
302 }
303 step.value = activeFilters.value.length ? "overview" : "fields";
304}
305
306watch(step, (to, from) => {
307 direction.value = stepOrder[to] >= stepOrder[from] ? "forward" : "back";
308});
309
310watch(activeFilters, (filters) => {
311 if (step.value === "overview" && !filters.length) step.value = "fields";
312});
313
314// On the overview step, "a" jumps to the add-filter (field) step.
315useEventListener(document, "keydown", (event: KeyboardEvent) => {
316 if (step.value !== "overview" || event.metaKey || event.ctrlKey) return;
317 const el = document.activeElement;
318 if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
319 return;
320 }
321 if (event.key === "a" || event.key === "A") {
322 event.preventDefault();
323 addNewFilter();
324 }
325});
326</script>
327
328<style scoped>
329/* Panels are transparent at rest so they never paint over the popover border;
330 they only need their own background while two of them overlap mid-swipe. */
331.slide-forward-enter-active,
332.slide-forward-leave-active,
333.slide-back-enter-active,
334.slide-back-leave-active {
335 background-color: var(--surface-base);
336 transition: transform 250ms cubic-bezier(0.2, 0, 0, 1),
337 opacity 250ms cubic-bezier(0.2, 0, 0, 1);
338}
339
340/* The outgoing panel leaves the flow so both panels can swipe at once; the
341 incoming forward panel stacks above it, like a navigation push. */
342.slide-forward-leave-active,
343.slide-back-leave-active {
344 position: absolute;
345 top: 0;
346 inset-inline: 0;
347}
348
349.slide-forward-enter-active {
350 position: relative;
351 z-index: 1;
352}
353
354.slide-forward-enter-from,
355.slide-back-leave-to {
356 transform: translateX(100%);
357}
358
359.slide-forward-leave-to,
360.slide-back-enter-from {
361 transform: translateX(-25%);
362 opacity: 0;
363}
364
365@media (prefers-reduced-motion: reduce) {
366 .slide-forward-enter-active,
367 .slide-forward-leave-active,
368 .slide-back-enter-active,
369 .slide-back-leave-active {
370 transition-duration: 0s;
371 }
372}
373</style>
374