feat: improve filter UX and rename filter module

- transactional replace: keep the existing condition until a new one is
  applied, swapping in place instead of deleting on click
- auto-return to overview after a discrete value pick (equals, is,
  timespan, single date/range)
- operator cycling shortcut moved to Alt/Opt + Up/Down (directional,
  no browser conflict) and works in list mode
- keyboard navigation for the 'Is' (Set/Not Set) list
- loading state for link-field search
- accessibility: listbox/option roles, aria-labels, focus-visible row
  actions, truncation tooltips
- rename filterCore.ts -> filter.ts, useFilterCore -> useFilter
This commit is contained in:
Ritvik Sardana
2026-06-14 17:00:56 +05:30
parent a6dd7c9222
commit af9d4cd995
6 changed files with 123 additions and 45 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
icon-left="lucide-chevron-left"
:label="label"
:size="size"
class="!justify-start truncate font-medium text-ink-gray-8 hover:bg-transparent hover:opacity-70 focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 active:bg-transparent active:outline-none active:ring-0 rtl:flex-row-reverse"
class="!justify-start truncate font-medium text-ink-gray-8 hover:bg-transparent hover:opacity-70 focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 active:bg-transparent active:outline-none active:ring-0 rtl:flex-row-reverse ps-1"
@click="$emit('back')"
/>
</template>
@@ -37,6 +37,7 @@
<Button
variant="ghost"
:label="filterSummary(filter)"
:tooltip="filterSummary(filter)"
class="!h-full min-w-0 flex-1 !justify-start !px-0 hover:!bg-transparent !pl-1.5"
@click="editFilter(filter)"
>
@@ -47,19 +48,21 @@
/>
</template>
</Button>
<div class="gap-1">
<div class="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
icon="lucide-repeat"
class="invisible shrink-0 group-hover:visible"
icon="lucide-replace"
:aria-label="__('Replace filter')"
:tooltip="__('Replace filter')"
:class="revealOnRowActivity"
@click="replaceFilter(filter)"
/>
<Button
variant="ghost"
icon="lucide-x"
class="invisible shrink-0 group-hover:visible"
:aria-label="__('Remove filter')"
:tooltip="__('Remove filter')"
:class="revealOnRowActivity"
@click="removeFilter(filter.index)"
/>
</div>
@@ -73,7 +76,7 @@
:size="'xs'"
class="!text-ink-gray-5"
:label="__('Add filter')"
@click="step = 'fields'"
@click="addNewFilter()"
tooltip="Add Filter (A)"
>
<template #prefix>
@@ -96,7 +99,7 @@
<template v-else-if="step === 'fields'">
<div
v-if="canGoBack"
class="flex h-9 items-center border-b border-outline-gray-1"
class="flex h-9 items-center border-b border-outline-gray-1 ps-1"
>
<BackButton :label="headerLabel" size="sm" @back="goBack" />
</div>
@@ -142,8 +145,8 @@ import {
fieldIcon,
FilterField,
filterSummary,
useFilterCore,
} from "./filterCore";
useFilter,
} from "./filter";
import FilterFieldList from "./FilterFieldList.vue";
import FilterTrigger from "./FilterTrigger.vue";
import FilterValueEditor from "./FilterValueEditor.vue";
@@ -155,17 +158,25 @@ const {
updateFilter,
removeFilter,
clearFilters,
} = useFilterCore();
} = useFilter();
const step = ref<"overview" | "fields" | "value">("fields");
const selectedField = ref<FilterField | null>(null);
const editingIndex = ref<number | null>(null);
// The row a "Replace" is targeting. The old condition is kept untouched until a
// new one is chosen, so backing out leaves the original filter intact; the
// replacement then lands at this same position.
const replacingIndex = ref<number | null>(null);
const editSession = ref(0);
// Where the value editor was opened from, so "back" returns there: the field
// list (when adding a new filter) or the overview (when editing an existing one).
const valueEditorOrigin = ref<"overview" | "fields">("overview");
let openPopoverFn: (() => void) | null = null;
// Row actions stay out of the way until the row is hovered or focused.
const revealOnRowActivity =
"opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100";
// used for animating back or forward between steps
const stepOrder = { overview: 0, fields: 1, value: 2 } as const;
const direction = ref<"forward" | "back">("forward");
@@ -202,6 +213,13 @@ function resetSteps() {
step.value = activeFilters.value.length ? "overview" : "fields";
selectedField.value = null;
editingIndex.value = null;
replacingIndex.value = null;
}
// Opens the field list to add a fresh filter, cancelling any pending replace.
function addNewFilter() {
replacingIndex.value = null;
step.value = "fields";
}
function selectField(field: FilterField) {
@@ -212,16 +230,18 @@ function selectField(field: FilterField) {
step.value = "value";
}
// "Replace" drops the existing condition and jumps to the field list so a
// different field can be picked in its place.
// "Replace" jumps to the field list to pick a different field, but keeps the
// existing condition in place — it's only swapped out once the new one is
// applied (see applyFilter), so abandoning the flow loses nothing.
function replaceFilter(filter: ActiveFilter) {
removeFilter(filter.index);
replacingIndex.value = filter.index;
step.value = "fields";
}
function editFilter(filter: ActiveFilter) {
selectedField.value = filter.field;
editingIndex.value = filter.index;
replacingIndex.value = null;
valueEditorOrigin.value = "overview";
editSession.value++;
step.value = "value";
@@ -229,11 +249,16 @@ function editFilter(filter: ActiveFilter) {
function applyFilter(operator: string, value: any) {
if (!selectedField.value) return;
if (editingIndex.value === null) {
// A pending replace overwrites its target row in place; otherwise we update
// the row being edited, or append a brand-new filter.
const targetIndex = replacingIndex.value ?? editingIndex.value;
if (targetIndex === null) {
editingIndex.value = addFilter(selectedField.value, operator, value);
} else {
updateFilter(editingIndex.value, selectedField.value, operator, value);
updateFilter(targetIndex, selectedField.value, operator, value);
editingIndex.value = targetIndex;
}
replacingIndex.value = null;
}
function clearCurrentFilter() {
@@ -269,7 +294,7 @@ useEventListener(document, "keydown", (event: KeyboardEvent) => {
}
if (event.key === "a" || event.key === "A") {
event.preventDefault();
step.value = "fields";
addNewFilter();
}
});
</script>
@@ -23,11 +23,18 @@
</template>
</TextInput>
</div>
<div ref="listEl" class="mt-1 max-h-64 overflow-y-auto p-1">
<div
ref="listEl"
role="listbox"
:aria-label="__('Fields')"
class="mt-1 max-h-64 overflow-y-auto p-1"
>
<button
v-for="(field, index) in filteredFields"
:key="field.fieldname"
:data-index="index"
role="option"
:aria-selected="index === activeIndex"
:class="[
'flex h-8 w-full items-center gap-2 rounded px-2 text-base text-ink-gray-8',
index === activeIndex ? 'bg-surface-gray-2' : '',
@@ -36,7 +43,9 @@
@click="$emit('select', field)"
>
<component :is="fieldIcon(field)" class="size-4 text-ink-gray-5" />
<span class="flex-1 truncate text-start">{{ field.label }}</span>
<span :title="field.label" class="flex-1 truncate text-start">{{
field.label
}}</span>
<LucideChevronRight class="size-4 text-ink-gray-4" />
</button>
<div
@@ -53,7 +62,7 @@
import { __ } from "@/translation";
import { TextInput } from "frappe-ui";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { fieldIcon, FilterField } from "./filterCore";
import { fieldIcon, FilterField } from "./filter";
interface P {
fields: FilterField[];
@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col">
<div
class="flex h-9 items-center justify-between gap-1 border-b border-outline-gray-1 pe-2"
class="flex h-9 items-center justify-between gap-1 border-b border-outline-gray-1 pe-2 ps-1"
>
<BackButton
:label="field.label"
@@ -11,7 +11,6 @@
/>
<Dropdown side="bottom" :options="operatorOptions">
<Button
:title="operatorLabel"
class="flex h-6 max-w-[150px] shrink-0 items-center gap-1 rounded bg-surface-gray-2 ps-2 pe-1 text-sm text-ink-gray-7 hover:bg-surface-gray-3"
variant="ghost"
icon-right="lucide-chevron-down"
@@ -38,13 +37,23 @@
<template #prefix>
<LucideSearch class="size-4 text-ink-gray-5" />
</template>
<template v-if="isSearching" #suffix>
<LucideLoaderCircle class="size-4 animate-spin text-ink-gray-5" />
</template>
</TextInput>
</div>
<div ref="listEl" class="mt-1 max-h-52 overflow-y-auto p-1.5 py-1">
<div
ref="listEl"
role="listbox"
:aria-label="field.label"
class="mt-1 max-h-52 overflow-y-auto p-1.5 py-1"
>
<button
v-for="(option, index) in options"
:key="option.value"
:data-index="index"
role="option"
:aria-selected="isSelected(option.value)"
:class="[
'flex h-8 w-full items-center gap-2 rounded px-1.5 text-base text-ink-gray-8',
index === activeIndex ? 'bg-surface-gray-2' : '',
@@ -52,14 +61,23 @@
@mousemove="activeIndex = index"
@click="pickOption(option.value)"
>
<span class="flex-1 truncate text-start">{{ option.label }}</span>
<span :title="option.label" class="flex-1 truncate text-start">{{
option.label
}}</span>
<LucideCheck
v-if="isSelected(option.value)"
class="size-4 text-ink-gray-7"
/>
</button>
<div
v-if="!options.length"
v-if="isSearching && !options.length"
class="flex h-8 items-center gap-2 px-2 text-base text-ink-gray-5"
>
<LucideLoaderCircle class="size-4 animate-spin" />
{{ __("Searching...") }}
</div>
<div
v-else-if="!options.length"
class="flex h-8 items-center px-2 text-base text-ink-gray-5"
>
{{ __("No results") }}
@@ -110,12 +128,7 @@ import {
TextInput,
} from "frappe-ui";
import { computed, nextTick, ref, watch } from "vue";
import {
ActiveFilter,
FilterField,
useFilterCore,
useLinkSearch,
} from "./filterCore";
import { ActiveFilter, FilterField, useFilter, useLinkSearch } from "./filter";
interface P {
field: FilterField;
@@ -132,7 +145,7 @@ interface E {
const props = withDefaults(defineProps<P>(), { filter: null });
const emit = defineEmits<E>();
const core = useFilterCore();
const core = useFilter();
const { getOperators, getDefaultOperator, getSelectOptions, timespanOptions } =
core;
@@ -154,7 +167,7 @@ const operatorLabel = computed(
operator.value
);
const { isMac } = useDevice();
const operatorTooltip = __("Change operator ({0})", isMac ? "⌘E" : "Ctrl+E");
const operatorTooltip = __("Change operator ({0})", isMac ? "⌥↓/↑" : "Alt+↓/↑");
const operatorOptions = computed(() =>
operators.value.map((option) => ({
label: option.label,
@@ -223,6 +236,10 @@ const dateRangeValue = computed(() =>
const linkSearch = useLinkSearch(linkDoctype(props.field));
// Link options are fetched from the server, so reflect the in-flight request
// instead of briefly showing "No results" while the search is loading.
const isSearching = computed(() => isLink.value && linkSearch.results.loading);
function linkDoctype(field: FilterField): string {
if (field.fieldname === "_assign") return "User";
return field.fieldtype === "Link" ? field.options || "" : "";
@@ -282,20 +299,29 @@ function splitRange(v: any): string[] {
return [start, end];
}
function moveActive(delta: number) {
const total = options.value.length;
if (total) activeIndex.value = (activeIndex.value + delta + total) % total;
}
function selectActive() {
const option = options.value[activeIndex.value];
if (option) pickOption(option.value);
}
function onKeydown(event: KeyboardEvent) {
if (event.isComposing) return;
// Let ⌥↑/⌥↓ bubble to the operator-cycle handler instead of moving the list.
if (event.altKey) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
const delta = event.key === "ArrowDown" ? 1 : -1;
const total = options.value.length;
if (total) activeIndex.value = (activeIndex.value + delta + total) % total;
moveActive(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (event.repeat) return;
const option = options.value[activeIndex.value];
if (option) pickOption(option.value);
selectActive();
} else if (event.key === "Backspace" && !search.value) {
event.stopPropagation();
emit("back");
@@ -386,19 +412,37 @@ watch(
{ immediate: true }
);
function cycleOperator() {
function cycleOperator(direction: number) {
const list = operators.value;
if (list.length < 2) return;
const currentIndex = list.findIndex((o) => o.value === operator.value);
operator.value = list[(currentIndex + 1) % list.length].value;
const total = list.length;
operator.value = list[(currentIndex + direction + total) % total].value;
}
useEventListener(document, "keydown", (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "e") {
// ⌥↓ / ⌥↑ (Alt on Windows) steps to the next / previous operator. A modifier
// is required because a value input is usually focused and would swallow a
// bare arrow key.
if (event.altKey && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault();
cycleOperator();
cycleOperator(event.key === "ArrowDown" ? 1 : -1);
return;
}
// List modes without a search box ("Is" → Set / Not Set) have no input to
// catch arrow keys, so drive the option list straight from the document.
if (isListMode.value && !showSearch.value) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveActive(event.key === "ArrowDown" ? 1 : -1);
return;
}
if (event.key === "Enter" && !event.repeat) {
event.preventDefault();
selectActive();
return;
}
}
// Backspace goes back — except while typing in a field, where the input's own
// handler manages an empty-Backspace and otherwise deletes characters.
if (event.key === "Backspace") {
@@ -30,7 +30,7 @@ export interface ActiveFilter {
index: number;
}
export interface FilterCore {
export interface Filter {
fields: ComputedRef<FilterField[]>;
activeFilters: ComputedRef<ActiveFilter[]>;
addFilter: (field: FilterField, operator: string, value: any) => number;
@@ -59,7 +59,7 @@ const typeString = ["Data", "Long Text", "Small Text", "Text Editor", "Text"];
const typeDate = ["Date", "Datetime"];
const typeRating = ["Rating"];
export function useFilterCore(): FilterCore {
export function useFilter(): Filter {
const listViewData = inject<any>("listViewData");
const listViewActions = inject<any>("listViewActions");
const { list, filterableFields } = listViewData;
@@ -1,3 +1,3 @@
export { default as Filter } from "./Filter.vue";
export { normalizeFilters } from "./filterCore";
export type { ActiveFilter, FilterCondition, FilterField } from "./filterCore";
export { normalizeFilters } from "./filter";
export type { ActiveFilter, FilterCondition, FilterField } from "./filter";