mirror of
https://github.com/hanzoai/helpdesk.git
synced 2026-10-12 16:56:02 +00:00
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:
@@ -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") {
|
||||
|
||||
+2
-2
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user