feat(grid): improve structured filter interactions

This commit is contained in:
zipg 2026-07-21 16:57:24 +08:00 committed by GitHub
parent 00f58ebe16
commit 54363b6edf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 356 additions and 20 deletions

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
import { Eye, EyeOff, Plus, Search, Trash2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
@ -31,24 +32,168 @@ const emit = defineEmits<{
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
"update:columnSearch": [value: string];
}>();
const columnSearchInputs = new Map<string, HTMLInputElement>();
const filterRuleElements = new Map<string, HTMLElement>();
const pendingValueFocus = new Set<string>();
const pendingKeyboardAddFocus = new Set<string>();
const openColumnSelectIds = ref(new Set<string>());
const activeColumnIndexes = ref<Record<string, number>>({});
let ruleIdsBeforeKeyboardAdd: Set<string> | undefined;
function usesExpandedLayout(mode: DataGridContextFilterMode) {
return filterModeUsesList(mode) || filterModeUsesRange(mode);
}
function updateRuleColumn(id: string, value: unknown) {
emit("updateRule", id, { columnName: String(value) });
function updateRuleColumn(rule: DataGridStructuredFilterRule, value: unknown, focusValue = true) {
if (focusValue && filterModeNeedsValue(rule.mode)) pendingValueFocus.add(rule.id);
emit("updateRule", rule.id, { columnName: String(value) });
emit("update:columnSearch", "");
}
function handleColumnSearchKeydown(event: KeyboardEvent) {
function setColumnSearchInput(id: string, element: unknown) {
if (!(element instanceof HTMLInputElement)) {
columnSearchInputs.delete(id);
return;
}
columnSearchInputs.set(id, element);
if (openColumnSelectIds.value.has(id)) window.requestAnimationFrame(() => element.focus());
}
function setFilterRuleElement(id: string, element: unknown) {
if (element instanceof HTMLElement) filterRuleElements.set(id, element);
else filterRuleElements.delete(id);
}
function activeColumnIndex(id: string): number {
return activeColumnIndexes.value[id] ?? -1;
}
function setActiveColumnIndex(id: string, index: number) {
const count = props.filteredColumns.length;
const nextIndex = count ? ((index % count) + count) % count : -1;
activeColumnIndexes.value = { ...activeColumnIndexes.value, [id]: nextIndex };
window.requestAnimationFrame(() => {
const listbox = columnSearchInputs.get(id)?.closest('[role="listbox"]');
listbox?.querySelectorAll<HTMLElement>('[role="option"]')[nextIndex]?.scrollIntoView?.({ block: "nearest" });
});
}
function setColumnSelectOpen(id: string, open: boolean) {
const next = new Set(openColumnSelectIds.value);
if (open) {
next.clear();
next.add(id);
} else {
next.delete(id);
}
openColumnSelectIds.value = next;
}
async function handleColumnSelectOpen(rule: DataGridStructuredFilterRule, open: boolean) {
setColumnSelectOpen(rule.id, open);
if (!open) return;
const selectedIndex = props.filteredColumns.indexOf(rule.columnName);
setActiveColumnIndex(rule.id, selectedIndex >= 0 ? selectedIndex : 0);
await nextTick();
window.requestAnimationFrame(() => columnSearchInputs.get(rule.id)?.focus());
}
function handleColumnCloseAutoFocus(id: string, event: Event) {
if (pendingKeyboardAddFocus.delete(id)) {
event.preventDefault();
return;
}
if (!pendingValueFocus.delete(id)) return;
event.preventDefault();
void focusFilterRuleValue(id);
}
async function focusFilterRuleValue(id: string) {
await nextTick();
window.requestAnimationFrame(() => filterRuleElements.get(id)?.querySelector<HTMLElement>("[data-filter-value-editor]")?.focus());
}
function updateColumnSearch(id: string, event: Event) {
emit("update:columnSearch", (event.target as HTMLInputElement).value);
setActiveColumnIndex(id, 0);
}
function addRuleAndOpenColumnSelect() {
ruleIdsBeforeKeyboardAdd = new Set(props.rules.map((item) => item.id));
emit("add");
}
function selectActiveColumn(rule: DataGridStructuredFilterRule, addAnother: boolean) {
const column = props.filteredColumns[activeColumnIndex(rule.id)];
if (!column) return;
updateRuleColumn(rule, column, !addAnother);
if (addAnother) pendingKeyboardAddFocus.add(rule.id);
setColumnSelectOpen(rule.id, false);
if (!addAnother) return;
addRuleAndOpenColumnSelect();
}
function moveColumnSearchCaret(event: KeyboardEvent) {
if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
const input = event.currentTarget as HTMLInputElement;
if (typeof input.setSelectionRange !== "function") return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? start;
const nextPosition = event.key === "ArrowLeft" ? (start === end ? Math.max(0, start - 1) : start) : start === end ? Math.min(input.value.length, end + 1) : end;
event.preventDefault();
event.stopPropagation();
input.setSelectionRange(nextPosition, nextPosition);
}
watch(
() => props.rules,
(rules) => {
if (!ruleIdsBeforeKeyboardAdd) return;
const addedRule = rules.find((item) => !ruleIdsBeforeKeyboardAdd?.has(item.id));
ruleIdsBeforeKeyboardAdd = undefined;
if (addedRule) void handleColumnSelectOpen(addedRule, true);
},
);
function handleColumnSearchKeydown(event: KeyboardEvent, rule: DataGridStructuredFilterRule) {
if (event.isComposing || event.key === "Process") {
event.stopPropagation();
return;
}
if (["Escape", "Tab", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(event.key)) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
setActiveColumnIndex(rule.id, activeColumnIndex(rule.id) + (event.key === "ArrowDown" ? 1 : -1));
return;
}
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
selectActiveColumn(rule, event.shiftKey);
return;
}
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
moveColumnSearchCaret(event);
return;
}
if (["Escape", "Tab"].includes(event.key)) return;
if (["Home", "End", "PageUp", "PageDown"].includes(event.key)) {
event.stopPropagation();
return;
}
if (!event.ctrlKey && !event.metaKey && !event.altKey && (event.key.length === 1 || event.key === "Backspace" || event.key === "Delete")) event.stopPropagation();
}
function handleValueEditorKeydown(event: KeyboardEvent) {
if (event.key !== "Enter" || event.isComposing) return;
event.preventDefault();
if (!event.shiftKey) {
emit("apply");
return;
}
event.stopPropagation();
if (!event.repeat) addRuleAndOpenColumnSelect();
}
</script>
<template>
@ -63,38 +208,61 @@ function handleColumnSearchKeydown(event: KeyboardEvent) {
<div v-if="index > 0" class="flex justify-center">
<Button variant="ghost" size="sm" class="h-6 px-2 text-[11px]" @click="emit('updateRule', rule.id, { conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND' })">{{ rule.conjunction }}</Button>
</div>
<div class="grid items-center gap-2" :class="usesExpandedLayout(rule.mode) ? 'grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1.2fr)_auto]'">
<Select :model-value="rule.columnName" :disabled="rule.disabled" @update:model-value="(value: any) => updateRuleColumn(rule.id, value)">
<SelectTrigger class="h-8 min-w-0 text-xs"><SelectValue :placeholder="t('grid.filterBuilderColumn')" /></SelectTrigger>
<SelectContent position="popper" class="max-h-72" :hide-scroll-buttons="true">
<SelectItem v-for="column in props.filteredColumns" :key="column" :value="column">{{ column }}</SelectItem>
<div :ref="(element) => setFilterRuleElement(rule.id, element)" class="grid items-center gap-2" :class="usesExpandedLayout(rule.mode) ? 'grid-cols-[minmax(0,1fr)_80px_auto]' : 'grid-cols-[minmax(0,1fr)_80px_minmax(0,1fr)_auto]'">
<Select :model-value="rule.columnName" :open="openColumnSelectIds.has(rule.id)" :disabled="rule.disabled" @update:model-value="(value: any) => updateRuleColumn(rule, value)" @update:open="(open: boolean) => handleColumnSelectOpen(rule, open)">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue v-if="rule.columnName">{{ rule.columnName }}</SelectValue>
<SelectValue v-else :placeholder="t('grid.filterBuilderColumn')" />
</SelectTrigger>
<SelectContent position="popper" class="max-h-72" :hide-scroll-buttons="true" @close-auto-focus="(event: Event) => handleColumnCloseAutoFocus(rule.id, event)">
<SelectItem
v-for="(column, columnIndex) in props.filteredColumns"
:key="column"
:value="column"
class="rounded-none"
:class="activeColumnIndex(rule.id) === columnIndex ? 'bg-accent text-accent-foreground' : ''"
:data-filter-active="activeColumnIndex(rule.id) === columnIndex ? '' : undefined"
@pointermove="setActiveColumnIndex(rule.id, columnIndex)"
>
{{ column }}
</SelectItem>
<div v-if="!props.filteredColumns.length" class="px-2 py-2 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoMatchingColumns") }}</div>
<div class="sticky bottom-0 mt-1 flex items-center gap-1.5 border-t bg-popover px-2 py-1.5">
<Search class="h-3.5 w-3.5 text-muted-foreground" />
<input
:ref="(element) => setColumnSearchInput(rule.id, element)"
:value="props.columnSearch"
class="h-7 min-w-0 flex-1 bg-transparent text-xs outline-none"
:placeholder="t('grid.filterBuilderSearchColumns')"
@input="emit('update:columnSearch', ($event.target as HTMLInputElement).value)"
@input="updateColumnSearch(rule.id, $event)"
@click.stop
@keydown="handleColumnSearchKeydown"
@keydown="handleColumnSearchKeydown($event, rule)"
@pointerdown.stop
/>
</div>
</SelectContent>
</Select>
<Select :model-value="rule.mode" :disabled="rule.disabled" @update:model-value="(value: any) => emit('updateRule', rule.id, { mode: value as DataGridContextFilterMode })">
<SelectTrigger class="h-8 min-w-0 text-xs"><SelectValue /></SelectTrigger>
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate"><SelectValue /></SelectTrigger>
<SelectContent
><SelectItem v-for="option in props.modeOptions" :key="option.value" :value="option.value">{{ t(option.labelKey) }}</SelectItem></SelectContent
><SelectItem v-for="option in props.modeOptions" :key="option.value" :value="option.value" class="rounded-none">{{ t(option.labelKey) }}</SelectItem></SelectContent
>
</Select>
<div v-if="filterModeUsesRange(rule.mode)" class="col-span-2 flex gap-2">
<Input :model-value="rule.rawValue" class="h-8 text-xs" :disabled="rule.disabled" :placeholder="t('grid.filterBuilderRangeStart')" @update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })" @keydown.enter.prevent="emit('apply')" />
<Input :model-value="rule.rawEndValue" class="h-8 text-xs" :disabled="rule.disabled" :placeholder="t('grid.filterBuilderRangeEnd')" @update:model-value="(value) => emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })" @keydown.enter.prevent="emit('apply')" />
<Input
data-filter-value-editor
:model-value="rule.rawValue"
class="h-8 text-xs"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderRangeStart')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@keydown="handleValueEditorKeydown"
/>
<Input :model-value="rule.rawEndValue" class="h-8 text-xs" :disabled="rule.disabled" :placeholder="t('grid.filterBuilderRangeEnd')" @update:model-value="(value) => emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })" @keydown="handleValueEditorKeydown" />
</div>
<textarea
v-else-if="filterModeUsesList(rule.mode)"
data-filter-value-editor
:value="rule.rawValue"
rows="2"
class="col-span-2 min-h-14 resize-y rounded-md border bg-transparent px-2.5 py-1 text-xs outline-none"
@ -106,12 +274,13 @@ function handleColumnSearchKeydown(event: KeyboardEvent) {
/>
<Input
v-else-if="filterModeNeedsValue(rule.mode)"
data-filter-value-editor
:model-value="rule.rawValue"
class="h-8 text-xs"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderValue')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@keydown.enter.prevent="emit('apply')"
@keydown="handleValueEditorKeydown"
/>
<div v-else class="flex h-8 items-center rounded-md border border-dashed px-2 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoValue") }}</div>
<div class="flex items-center gap-1" :class="usesExpandedLayout(rule.mode) ? 'col-start-3 row-start-1 row-span-2' : ''">

View File

@ -130,7 +130,7 @@ onUnmounted(onResizeEnd);
<span v-if="filterButtonCount" class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground">{{ filterButtonCount }}</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-[380px] max-w-[calc(100vw-24px)] gap-3 p-3">
<PopoverContent align="start" class="w-[480px] max-w-[calc(100vw-24px)] gap-3 p-3">
<div class="flex items-center justify-between gap-3">
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="emit('addRule')"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("grid.filterBuilderAddRule") }}</Button>

View File

@ -311,18 +311,185 @@ describe("DataGridColumnHeader", () => {
});
describe("DataGridFilterBuilder", () => {
it("isolates text-entry keys while preserving popup navigation keys", () => {
const mounted = mountComponent(DataGridFilterBuilder, { rules: [{ id: "r1", columnName: "id", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }], columns: ["id"], filteredColumns: ["id"], modeOptions: [{ value: "equals", labelKey: "equals" }], columnSearch: "" });
it("clips long selected values inside the filter grid", () => {
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "appointmentStatusWithAnExceptionallyLongName", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
columns: ["appointmentStatusWithAnExceptionallyLongName", "name"],
filteredColumns: ["name"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
});
const selects = findAll(mounted.root, (node) => node.props["data-stub"] === "Select");
const selectContents = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectContent");
const triggers = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectTrigger");
const selectValues = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectValue");
const items = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem");
const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[minmax(0,1fr)_80px_minmax(0,1fr)_auto]"));
const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns");
const valueEditor = findOne(mounted.root, (node) => node.props["data-filter-value-editor"] === "");
expect(selects).toHaveLength(2);
expect(selects[0].props["onUpdate:open"]).toEqual(expect.any(Function));
expect(selects[0].props["onUpdate:modelValue"]).toEqual(expect.any(Function));
expect(selectContents[0].props.onCloseAutoFocus).toEqual(expect.any(Function));
expect(triggers).toHaveLength(2);
expect(hostText(selectValues[0])).toBe("appointmentStatusWithAnExceptionallyLongName");
expect(items).toHaveLength(2);
expect(items.every((item) => String(item.props.class).includes("rounded-none"))).toBe(true);
expect(searchInput.props.placeholder).toBe("grid.filterBuilderSearchColumns");
expect(valueEditor.props.placeholder).toBe("grid.filterBuilderValue");
expect(String(ruleGrid.props.class)).toContain("grid-cols-[minmax(0,1fr)_80px_minmax(0,1fr)_auto]");
for (const trigger of triggers) {
expect(String(trigger.props.class)).toContain("w-full");
expect(String(trigger.props.class)).toContain("overflow-hidden");
expect(String(trigger.props.class)).toContain("[&_[data-slot=select-value]]:min-w-0");
expect(String(trigger.props.class)).toContain("[&_[data-slot=select-value]]:truncate");
}
});
it("keeps search focus while navigating and selecting filtered columns", async () => {
const onUpdateRule = vi.fn();
const onAdd = vi.fn();
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
columns: ["id", "image_size_bytes"],
filteredColumns: ["id", "image_size_bytes"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
onUpdateRule,
onAdd,
});
const columnSelect = findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[0];
const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns");
columnSelect.props["onUpdate:open"](true);
await nextTick();
let columnItems = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem").slice(0, 2);
expect(columnItems[0].props["data-filter-active"]).toBe("");
expect(dispatch(searchInput, "keydown", { key: "a" }).propagationStopped).toBe(true);
expect(dispatch(searchInput, "keydown", { key: "Backspace" }).propagationStopped).toBe(true);
expect(dispatch(searchInput, "keydown", { key: "ArrowDown" }).propagationStopped).toBe(false);
const arrowDown = dispatch(searchInput, "keydown", { key: "ArrowDown" });
expect(arrowDown.defaultPrevented).toBe(true);
expect(arrowDown.propagationStopped).toBe(true);
await nextTick();
columnItems = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem").slice(0, 2);
expect(columnItems[1].props["data-filter-active"]).toBe("");
const leftInput = { value: "image_", selectionStart: 4, selectionEnd: 4, setSelectionRange: vi.fn() };
const leftArrow = dispatch(searchInput, "keydown", { key: "ArrowLeft", currentTarget: leftInput });
expect(leftArrow.defaultPrevented).toBe(true);
expect(leftArrow.propagationStopped).toBe(true);
expect(leftInput.setSelectionRange).toHaveBeenCalledWith(3, 3);
const rightInput = { value: "image_", selectionStart: 1, selectionEnd: 4, setSelectionRange: vi.fn() };
const rightArrow = dispatch(searchInput, "keydown", { key: "ArrowRight", currentTarget: rightInput });
expect(rightArrow.defaultPrevented).toBe(true);
expect(rightArrow.propagationStopped).toBe(true);
expect(rightInput.setSelectionRange).toHaveBeenCalledWith(4, 4);
const enter = dispatch(searchInput, "keydown", { key: "Enter" });
expect(enter.defaultPrevented).toBe(true);
expect(enter.propagationStopped).toBe(true);
expect(onUpdateRule).toHaveBeenCalledWith("r1", { columnName: "image_size_bytes" });
expect(onAdd).not.toHaveBeenCalled();
expect(dispatch(searchInput, "keydown", { key: "Process", isComposing: true }).propagationStopped).toBe(true);
});
it("adds another rule after selecting a column with shift-enter", async () => {
const onUpdateRule = vi.fn();
const secondRule = { id: "r2", columnName: "id", mode: "equals" as const, rawValue: "", rawEndValue: "", conjunction: "AND" as const };
let mounted: ReturnType<typeof mountComponent>;
const onAdd = vi.fn(() => {
void mounted.setProps({ rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }, secondRule] });
});
mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
columns: ["id"],
filteredColumns: ["id"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
onUpdateRule,
onAdd,
});
const columnSelect = findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[0];
const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns");
columnSelect.props["onUpdate:open"](true);
await nextTick();
const shiftEnter = dispatch(searchInput, "keydown", { key: "Enter", shiftKey: true });
expect(shiftEnter.defaultPrevented).toBe(true);
expect(shiftEnter.propagationStopped).toBe(true);
expect(onUpdateRule).toHaveBeenCalledWith("r1", { columnName: "id" });
expect(onAdd).toHaveBeenCalledOnce();
await nextTick();
const columnSelects = findAll(mounted.root, (node) => node.props["data-stub"] === "Select").filter((_node, index) => index % 2 === 0);
const firstSelectContent = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectContent")[0];
const closeAutoFocus = dispatch(firstSelectContent, "closeAutoFocus");
expect(closeAutoFocus.defaultPrevented).toBe(true);
expect(columnSelects).toHaveLength(2);
expect(columnSelects[0].props.open).toBe(false);
expect(columnSelects[1].props.open).toBe(true);
});
it("adds a rule instead of applying when shift-enter is pressed in a value editor", () => {
const onAdd = vi.fn();
const onApply = vi.fn();
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "id", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" }],
columns: ["id"],
filteredColumns: ["id"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
onAdd,
onApply,
});
const valueEditor = findOne(mounted.root, (node) => node.props["data-filter-value-editor"] === "");
const shiftEnter = dispatch(valueEditor, "keydown", { key: "Enter", shiftKey: true, repeat: false });
expect(shiftEnter.defaultPrevented).toBe(true);
expect(shiftEnter.propagationStopped).toBe(true);
expect(onAdd).toHaveBeenCalledOnce();
expect(onApply).not.toHaveBeenCalled();
dispatch(valueEditor, "keydown", { key: "Enter", shiftKey: false });
expect(onApply).toHaveBeenCalledOnce();
});
});
describe("DataGridQueryControls", () => {
it("gives filter rules enough horizontal space for longer column names", () => {
const mounted = mountComponent(DataGridQueryControls, {
whereInput: "",
orderByInput: "",
columns: ["appointmentStatusWithAnExceptionallyLongName"],
conditionColumns: ["appointmentStatusWithAnExceptionallyLongName"],
historyScope: {},
canUseWhereSearch: true,
compact: false,
leadingBorder: false,
filterBuilderOpen: true,
filterButtonActive: false,
filterButtonCount: 0,
hasLocalColumnFilters: false,
localFilterCount: 0,
localFilterSummaries: [],
rules: [{ id: "r1", columnName: "appointmentStatusWithAnExceptionallyLongName", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
filteredColumns: ["appointmentStatusWithAnExceptionallyLongName"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
applyWhere: vi.fn(),
applyOrderBy: vi.fn(),
clearOrderBy: vi.fn(),
});
const popoverContent = findOne(mounted.root, (node) => node.props["data-stub"] === "PopoverContent");
expect(String(popoverContent.props.class)).toContain("w-[480px]");
expect(String(popoverContent.props.class)).toContain("max-w-[calc(100vw-24px)]");
});
it("keeps filter actions available in the popover", () => {
const clearFilters = vi.fn();
const applyFilters = vi.fn();