Filter AST Side Panel
The grouped Filter AST editor is not limited to a column popup. Mount the same editor in a persistent side panel, drawer, or inspector and keep the grid plugin responsible for configured fields and operators, validation, preview, and applying the canonical tree.
Source code
import type { FilterAstChangeEventDetail, FilterAstEditorHandle } from '@revolist/revogrid-pro';
import { defineCustomElements } from '@revolist/revogrid/loader';
import {
createFilterAstSidePanelRows,
filterAstSidePanelColumns,
filterAstSidePanelConfig,
filterAstSidePanelEditorOptions,
filterAstSidePanelPlugins,
getFilterAstSidePanelVisibleCount,
getSidePanelFilterPlugin,
type SupportTicket,
} from './FilterAstSidePanel.shared';
import './filter-ast-side-panel.scss';
defineCustomElements();
export function load(parentSelector: string, inputRows?: SupportTicket[]) {
const parent = document.querySelector(parentSelector);
if (!parent) throw new Error(`Could not find ${parentSelector}.`);
const rows = inputRows?.length ? inputRows : createFilterAstSidePanelRows();
const root = document.createElement('section');
root.className = 'filter-ast-side-panel';
root.dataset.testid = 'filter-ast-side-panel';
root.innerHTML = `
<aside class="filter-ast-side-panel__rail" aria-label="Filter tree editor">
<div data-editor-host class="filter-ast-side-panel__editor-host"></div>
</aside>
<main class="filter-ast-side-panel__content">
<div class="filter-ast-side-panel__result">
<div><h3>Support queue</h3><p>The side panel and grouped dialog use the same canonical editor.</p></div>
<span data-count class="filter-ast-side-panel__count" data-testid="filter-ast-side-panel-count"></span>
</div>
<div data-grid></div>
<details class="filter-ast-side-panel__ast">
<summary>View the effective Filter AST</summary>
<pre data-ast data-testid="filter-ast-side-panel-json">undefined</pre>
</details>
</main>`;
const grid = document.createElement('revo-grid');
grid.className = 'filter-ast-side-panel__grid demo-preview-content-sized-grid cell-border';
grid.theme = 'compact';
grid.columns = filterAstSidePanelColumns;
grid.plugins = filterAstSidePanelPlugins;
grid.filter = filterAstSidePanelConfig;
grid.stretch = 'last';
grid.hideAttribution = true;
root.querySelector<HTMLElement>('[data-grid]')!.replaceWith(grid);
const editorHost = root.querySelector<HTMLElement>('[data-editor-host]')!;
const count = root.querySelector<HTMLElement>('[data-count]')!;
const ast = root.querySelector<HTMLElement>('[data-ast]')!;
let editor: FilterAstEditorHandle | undefined;
let initializing = false;
let destroyed = false;
const updateCount = async () => {
count.textContent = `${await getFilterAstSidePanelVisibleCount(grid)} of ${rows.length} tickets`;
};
const onFilterAstChange = (event: Event) => {
const detail = (event as CustomEvent<FilterAstChangeEventDetail>).detail;
ast.textContent = JSON.stringify(detail.filterAst, null, 2) ?? 'undefined';
};
const initialize = async () => {
if (destroyed || editor || initializing) return;
initializing = true;
try {
const plugin = await getSidePanelFilterPlugin(grid);
if (destroyed || editor) return;
editor = plugin.mountFilterAstEditor(editorHost, filterAstSidePanelEditorOptions);
ast.textContent = JSON.stringify(plugin.getFilterAst(), null, 2) ?? 'undefined';
grid.addEventListener('filterastchange', onFilterAstChange);
grid.addEventListener('afterfilterapply', updateCount);
await updateCount();
} finally {
initializing = false;
}
};
grid.addEventListener('aftergridinit', initialize, { once: true });
parent.append(root);
grid.source = rows;
void customElements.whenDefined('revo-grid').then(initialize);
return () => {
destroyed = true;
editor?.destroy();
grid.removeEventListener('aftergridinit', initialize);
grid.removeEventListener('filterastchange', onFilterAstChange);
grid.removeEventListener('afterfilterapply', updateCount);
root.remove();
};
}
<template>
<section class="filter-ast-side-panel" data-testid="filter-ast-side-panel">
<aside class="filter-ast-side-panel__rail" aria-label="Filter tree editor">
<div ref="editorHost" class="filter-ast-side-panel__editor-host" />
</aside>
<main class="filter-ast-side-panel__content">
<div class="filter-ast-side-panel__result">
<div>
<h3>Support queue</h3>
<p>The side panel and grouped dialog use the same canonical editor.</p>
</div>
<span class="filter-ast-side-panel__count" data-testid="filter-ast-side-panel-count">
{{ visibleCount }} of {{ rows.length }} tickets
</span>
</div>
<RevoGrid
class="filter-ast-side-panel__grid demo-preview-content-sized-grid cell-border"
:theme="isDark ? 'darkCompact' : 'compact'"
:columns="filterAstSidePanelColumns"
:source="rows"
:plugins="filterAstSidePanelPlugins"
:filter="filterAstSidePanelConfig"
stretch="last"
hide-attribution
@aftergridinit="mountEditor"
@filterastchange="onFilterAstChange"
@afterfilterapply="updateVisibleCount"
/>
<details class="filter-ast-side-panel__ast">
<summary>View the effective Filter AST</summary>
<pre data-testid="filter-ast-side-panel-json">{{ astJson }}</pre>
</details>
</main>
</section>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import RevoGrid from '@revolist/vue3-datagrid';
import type { FilterAstChangeEventDetail, FilterAstEditorHandle } from '@revolist/revogrid-pro';
import { currentThemeVue } from '../composables/useRandomData';
import {
createFilterAstSidePanelRows,
filterAstSidePanelColumns,
filterAstSidePanelConfig,
filterAstSidePanelEditorOptions,
filterAstSidePanelPlugins,
getFilterAstSidePanelVisibleCount,
getSidePanelFilterPlugin,
} from './FilterAstSidePanel.shared';
import './filter-ast-side-panel.scss';
const props = defineProps<{ rows?: ReturnType<typeof createFilterAstSidePanelRows> }>();
const { isDark } = currentThemeVue();
const editorHost = ref<HTMLElement>();
const rows = ref(props.rows?.length ? props.rows : createFilterAstSidePanelRows());
const visibleCount = ref(rows.value.length);
const astJson = ref('undefined');
let editor: FilterAstEditorHandle | undefined;
async function updateVisibleCount(event: Event) {
visibleCount.value = await getFilterAstSidePanelVisibleCount(
event.target as HTMLRevoGridElement,
);
}
function onFilterAstChange(event: CustomEvent<FilterAstChangeEventDetail>) {
astJson.value = JSON.stringify(event.detail.filterAst, null, 2) ?? 'undefined';
}
async function mountEditor(event: Event) {
const grid = event.target as HTMLRevoGridElement;
const host = editorHost.value;
if (editor || !host) return;
const plugin = await getSidePanelFilterPlugin(grid);
if (editor || !host.isConnected) return;
editor = plugin.mountFilterAstEditor(host, filterAstSidePanelEditorOptions);
astJson.value = JSON.stringify(plugin.getFilterAst(), null, 2) ?? 'undefined';
visibleCount.value = await getFilterAstSidePanelVisibleCount(grid);
}
onBeforeUnmount(() => editor?.destroy());
</script>
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { RevoGrid } from '@revolist/react-datagrid';
import type {
FilterAstChangeEventDetail,
FilterAstEditorHandle,
} from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import {
createFilterAstSidePanelRows,
filterAstSidePanelColumns,
filterAstSidePanelConfig,
filterAstSidePanelEditorOptions,
filterAstSidePanelPlugins,
getFilterAstSidePanelVisibleCount,
getSidePanelFilterPlugin,
} from './FilterAstSidePanel.shared';
import './filter-ast-side-panel.scss';
const { isDark } = currentTheme();
export default function FilterAstSidePanel({
rows: inputRows,
}: {
rows?: ReturnType<typeof createFilterAstSidePanelRows>;
}) {
const editorHostRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<FilterAstEditorHandle>();
const rows = useMemo(
() => (inputRows?.length ? inputRows : createFilterAstSidePanelRows()),
[inputRows],
);
const columns = useMemo(() => [...filterAstSidePanelColumns], []);
const plugins = useMemo(() => [...filterAstSidePanelPlugins], []);
const filter = useMemo(() => ({ ...filterAstSidePanelConfig }), []);
const [visibleCount, setVisibleCount] = useState(rows.length);
const [astJson, setAstJson] = useState('undefined');
const [darkTheme, setDarkTheme] = useState(isDark);
useEffect(() => {
const observer = new MutationObserver(() => setDarkTheme(isDark()));
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class'],
});
return () => observer.disconnect();
}, []);
const mountEditor = useCallback(async (event: Event) => {
const grid = event.currentTarget as HTMLRevoGridElement;
const host = editorHostRef.current;
if (editorRef.current || !host) return;
const plugin = await getSidePanelFilterPlugin(grid);
if (editorRef.current || !host.isConnected) return;
editorRef.current = plugin.mountFilterAstEditor(host, filterAstSidePanelEditorOptions);
setAstJson(JSON.stringify(plugin.getFilterAst(), null, 2) ?? 'undefined');
setVisibleCount(await getFilterAstSidePanelVisibleCount(grid));
}, []);
const updateVisibleCount = useCallback(async (event: Event) => {
const grid = event.currentTarget as HTMLRevoGridElement;
setVisibleCount(await getFilterAstSidePanelVisibleCount(grid));
}, []);
const onFilterAstChange = useCallback((event: CustomEvent<FilterAstChangeEventDetail>) => {
setAstJson(JSON.stringify(event.detail.filterAst, null, 2) ?? 'undefined');
}, []);
useEffect(() => () => editorRef.current?.destroy(), []);
return (
<section
className="filter-ast-side-panel"
data-testid="filter-ast-side-panel"
>
<aside
className="filter-ast-side-panel__rail"
aria-label="Filter tree editor"
>
<div
ref={editorHostRef}
className="filter-ast-side-panel__editor-host"
/>
</aside>
<main className="filter-ast-side-panel__content">
<div className="filter-ast-side-panel__result">
<div>
<h3>Support queue</h3>
<p>
The side panel and grouped dialog use the same canonical editor.
</p>
</div>
<span
className="filter-ast-side-panel__count"
data-testid="filter-ast-side-panel-count"
>
{visibleCount} of {rows.length} tickets
</span>
</div>
<RevoGrid
className="filter-ast-side-panel__grid demo-preview-content-sized-grid cell-border"
theme={darkTheme ? 'darkCompact' : 'compact'}
columns={columns}
source={rows}
plugins={plugins}
filter={filter}
onAftergridinit={mountEditor}
onFilterastchange={onFilterAstChange}
onAfterfilterapply={updateVisibleCount}
stretch="last"
hideAttribution
/>
<details className="filter-ast-side-panel__ast">
<summary>View the effective Filter AST</summary>
<pre data-testid="filter-ast-side-panel-json">{astJson}</pre>
</details>
</main>
</section>
);
}
import {
Component,
ElementRef,
Input,
OnDestroy,
signal,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import { RevoGrid } from '@revolist/angular-datagrid';
import type {
FilterAstChangeEventDetail,
FilterAstEditorHandle,
} from '@revolist/revogrid-pro';
import type { SupportTicket } from './FilterAst.shared';
import { currentTheme } from '../composables/useRandomData';
import {
createFilterAstSidePanelRows,
filterAstSidePanelColumns,
filterAstSidePanelConfig,
filterAstSidePanelEditorOptions,
filterAstSidePanelPlugins,
getFilterAstSidePanelVisibleCount,
getSidePanelFilterPlugin,
} from './FilterAstSidePanel.shared';
@Component({
selector: 'filter-ast-side-panel-grid',
standalone: true,
imports: [RevoGrid],
template: `
<section class="filter-ast-side-panel" data-testid="filter-ast-side-panel">
<aside
class="filter-ast-side-panel__rail"
aria-label="Filter tree editor"
>
<div #editorHost class="filter-ast-side-panel__editor-host"></div>
</aside>
<main class="filter-ast-side-panel__content">
<div class="filter-ast-side-panel__result">
<div>
<h3>Support queue</h3>
<p>
The side panel and grouped dialog use the same canonical editor.
</p>
</div>
<span
class="filter-ast-side-panel__count"
data-testid="filter-ast-side-panel-count"
>
{{ visibleCount() }} of {{ rows.length }} tickets
</span>
</div>
<revo-grid
class="filter-ast-side-panel__grid demo-preview-content-sized-grid cell-border"
[theme]="theme"
[columns]="columns"
[source]="rows"
[plugins]="plugins"
[filter]="filter"
stretch="last"
[hideAttribution]="true"
(aftergridinit)="mountEditor($event)"
(filterastchange)="onFilterAstChange($event)"
(afterfilterapply)="updateVisibleCount($event)"
></revo-grid>
<details class="filter-ast-side-panel__ast">
<summary>View the effective Filter AST</summary>
<pre data-testid="filter-ast-side-panel-json">{{ astJson() }}</pre>
</details>
</main>
</section>
`,
styleUrls: ['./filter-ast-side-panel.scss'],
encapsulation: ViewEncapsulation.None,
})
export class FilterAstSidePanelGridComponent
implements OnDestroy
{
@ViewChild('editorHost', { read: ElementRef })
editorHost!: ElementRef<HTMLElement>;
private editor?: FilterAstEditorHandle;
rows: SupportTicket[] = createFilterAstSidePanelRows();
readonly columns = filterAstSidePanelColumns;
readonly plugins = filterAstSidePanelPlugins;
readonly filter = filterAstSidePanelConfig;
readonly visibleCount = signal(this.rows.length);
readonly astJson = signal('undefined');
readonly theme = currentTheme().isDark() ? 'darkCompact' : 'compact';
@Input() set sourceRows(value: SupportTicket[] | undefined) {
if (value?.length) {
this.rows = value;
this.visibleCount.set(value.length);
}
}
async mountEditor(event: Event) {
const grid = event.target as HTMLRevoGridElement;
const host = this.editorHost.nativeElement;
if (this.editor || !host) return;
const plugin = await getSidePanelFilterPlugin(grid);
if (this.editor || !host.isConnected) return;
this.editor = plugin.mountFilterAstEditor(host, filterAstSidePanelEditorOptions);
this.astJson.set(JSON.stringify(plugin.getFilterAst(), null, 2) ?? 'undefined');
this.visibleCount.set(await getFilterAstSidePanelVisibleCount(grid));
}
ngOnDestroy() {
this.editor?.destroy();
}
async updateVisibleCount(event: Event) {
this.visibleCount.set(await getFilterAstSidePanelVisibleCount(
event.target as HTMLRevoGridElement,
));
}
onFilterAstChange(event: Event) {
const detail = (event as CustomEvent<FilterAstChangeEventDetail>).detail;
this.astJson.set(JSON.stringify(detail.filterAst, null, 2) ?? 'undefined');
}
}
import type { ColumnFilterConfig, ColumnRegular } from '@revolist/revogrid';
import {
AdvanceFilterPlugin,
FILTER_ARRAY_TAGS,
FILTER_BOOLEAN,
FILTER_DATE,
FIlTER_SELECTION,
type FilterAst,
type FilterAstCondition,
type FilterAstGroup,
} from '@revolist/revogrid-pro';
import {
captureWorkbenchReference,
createFilterAstRows,
type SupportTicket,
} from './FilterAst.shared';
export type { SupportTicket } from './FilterAst.shared';
export const filterAstSidePanelColumns: ColumnRegular[] = [
{ name: 'Ticket', prop: 'id', size: 76, filter: 'number' },
{ name: 'Subject', prop: 'subject', size: 210, filter: 'string' },
{ name: 'Status', prop: 'status', size: 116, filter: [FIlTER_SELECTION] },
{
name: 'Priority', prop: 'priority', size: 100, filter: 'number',
filterAstValueEditor: { kind: 'slider', min: 1, max: 5, step: 1 },
},
{ name: 'VIP', prop: 'vip', size: 76, filter: FILTER_BOOLEAN },
{
name: 'Tags', prop: 'tags', size: 170, columnType: 'array',
filter: [FIlTER_SELECTION, FILTER_ARRAY_TAGS],
},
{
name: 'Due date', prop: 'dueDate', size: 132, columnType: 'date', filter: FILTER_DATE,
},
];
export const filterAstSidePanelPlugins = [AdvanceFilterPlugin];
export const filterAstSidePanelInitialAst: FilterAstGroup = {
type: 'group',
operator: 'and',
children: [
{
type: 'condition', field: 'status', operator: 'in',
valueType: 'array', value: ['Pending'],
},
{
type: 'condition', field: 'priority', operator: 'greaterThanOrEqual',
valueType: 'number', value: 1,
},
{
type: 'condition', field: 'vip', operator: 'isFalse', valueType: 'boolean',
},
{
type: 'condition', field: 'tags', operator: 'arrayTagsMatch', valueType: 'unknown',
value: { mode: 'any', values: ['api'], emptyOnly: false, exact: false },
},
{
type: 'condition', field: 'dueDate', operator: 'dateOnOrAfter',
valueType: 'date', value: '2000-01-01',
},
{
type: 'condition', field: 'subject', operator: 'contains',
valueType: 'string', value: 'i',
},
],
};
export const filterAstSidePanelEditorOptions = {
preset: 'fixed-list' as const,
conditionSlots: filterAstSidePanelInitialAst.children.filter(
(condition): condition is FilterAstCondition => condition.type === 'condition',
),
};
export const filterAstSidePanelConfig: ColumnFilterConfig = {
columnFilterButton: false,
groupedFilter: {},
filterAst: filterAstSidePanelInitialAst,
};
export function createFilterAstSidePanelRows(): SupportTicket[] {
return createFilterAstRows(captureWorkbenchReference());
}
export async function getSidePanelFilterPlugin(grid?: HTMLRevoGridElement) {
if (!grid) throw new Error('RevoGrid is not available.');
const plugin = (await grid.getPlugins()).find(item => item instanceof AdvanceFilterPlugin);
if (!plugin) throw new Error('AdvanceFilterPlugin is not registered.');
return plugin;
}
export async function getFilterAstSidePanelVisibleCount(grid: HTMLRevoGridElement) {
return (await grid.getVisibleSource()).length;
}
.filter-ast-side-panel {
--ast-panel-accent-strong: #087a49;
--ast-panel-border: #dce4ec;
--ast-panel-muted: #64748b;
--ast-panel-surface: transparent;
--ast-panel-surface-subtle: #f8fafc;
--ast-panel-text: inherit;
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(360px, 430px) minmax(0, 1fr);
width: 100%;
min-width: 0;
min-height: 620px;
overflow: hidden;
border: 1px solid var(--ast-panel-border);
border-radius: 16px;
background: var(--ast-panel-surface);
color: var(--ast-panel-text);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.filter-ast-side-panel *,
.filter-ast-side-panel *::before,
.filter-ast-side-panel *::after { box-sizing: border-box; }
.filter-ast-side-panel__rail {
min-width: 0;
height: 620px;
overflow: hidden;
border-right: 1px solid var(--ast-panel-border);
background: var(--ast-panel-surface);
}
.filter-ast-side-panel__editor-host {
--rv-filter-ast-editor-surface-bg: var(--ast-panel-surface);
--rv-filter-ast-editor-control-bg: var(--ast-panel-surface-subtle);
--rv-filter-ast-editor-text-color: var(--ast-panel-text);
--rv-filter-ast-editor-control-border: var(--ast-panel-border);
--rv-filter-ast-editor-divider: var(--ast-panel-border);
--rv-filter-ast-editor-muted-color: var(--ast-panel-muted);
height: 100%;
min-height: 0;
}
.filter-ast-side-panel__content {
display: grid;
align-content: start;
gap: 16px;
min-width: 0;
padding: 22px;
background: var(--ast-panel-surface-subtle);
}
.filter-ast-side-panel__result {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.filter-ast-side-panel__result h3 { margin: 0; font-size: 1rem; }
.filter-ast-side-panel__result p {
margin: 3px 0 0;
color: var(--ast-panel-muted);
font-size: 0.8rem;
}
.filter-ast-side-panel__count {
flex: 0 0 auto;
color: var(--ast-panel-accent-strong);
font-size: 0.84rem;
font-weight: 700;
}
.filter-ast-side-panel__grid {
display: block;
width: 100%;
min-width: 0;
height: 430px;
overflow: hidden;
border: 1px solid var(--ast-panel-border);
border-radius: 12px;
background: var(--ast-panel-surface);
}
.filter-ast-side-panel__ast {
overflow: hidden;
border: 1px solid var(--ast-panel-border);
border-radius: 10px;
background: var(--ast-panel-surface);
}
.filter-ast-side-panel__ast summary {
padding: 11px 13px;
color: var(--ast-panel-accent-strong);
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
}
.filter-ast-side-panel__ast pre {
max-height: 240px;
margin: 0;
padding: 14px;
overflow: auto;
border-top: 1px solid var(--ast-panel-border);
color: var(--ast-panel-text);
font-size: 0.72rem;
line-height: 1.45;
white-space: pre;
}
@media (max-width: 860px) {
.filter-ast-side-panel { grid-template-columns: 1fr; }
.filter-ast-side-panel__rail {
height: min(620px, 70vh);
border-right: 0;
border-bottom: 1px solid var(--ast-panel-border);
}
}
@media (max-width: 520px) {
.filter-ast-side-panel__content { padding: 16px; }
.filter-ast-side-panel__result { align-items: flex-start; flex-direction: column; }
.filter-ast-side-panel__grid { height: 360px; }
}
@media (prefers-color-scheme: dark) {
.filter-ast-side-panel {
--ast-panel-accent-strong: #7be0ad;
--ast-panel-border: #334155;
--ast-panel-muted: #94a3b8;
--ast-panel-surface: #111827;
--ast-panel-surface-subtle: #0f172a;
--ast-panel-text: #e5edf6;
}
}
Resolve the installed plugin after the grid initializes, mount the editor into a connected host, and destroy the returned handle with your framework component:
const plugin = (await grid.getPlugins()).find( item => item instanceof AdvanceFilterPlugin,);
const editor = plugin.mountFilterAstEditor(editorHost, { preset: 'fixed-list', conditionSlots: initialAst.children,});
// Component teardowneditor.destroy();When the mounted editor is the only filtering surface, set
filter.columnFilterButton to false. This removes the compact button and its
hover/focus layout reservation from every column header while leaving filtering,
the active AST, and the mounted editor enabled.
The fixed-list preset accepts a flat root AND tree of predeclared conditions. Pass those definitions as conditionSlots so their field and predicate remain visible after clearing. Fields stay fixed, while each predicate badge can select another operator supported by that column. Each active row has a compact, accessible clear action. Clearing removes its value from the draft and omits the condition from the canonical AST; entering or selecting a new valid value applies it again. A cleared valueless predicate, such as “VIP is false,” makes its predicate badge the explicit Apply action. Clear all clears every slot, applies undefined, and restores every grid row without removing the predefined controls.
Choose reusable value controls in the column configuration. The example renders Priority’s compatible scalar numeric predicates with the shared single-value slider without adding editor configuration at the mount site:
{ name: 'Priority', prop: 'priority', filter: 'number', filterAstValueEditor: { kind: 'slider', min: 1, max: 5, step: 1 },}Omitted slider bounds are derived from the grid source. Use mount-level
valueEditors only when a particular surface needs a field- or
field/operator-specific override; those overrides take precedence over the
column default without changing the canonical predicate.
The preset keeps each field fixed as a plain anchor, presents each read-only operator phrase as a compact theme-aware badge, exposes only the configured value editor, omits placeholder boxes for operators that need no value, and applies effective changes after a short debounce. Builder chrome, grouping, add/remove/reorder actions, mode switch, and footer are intentionally absent. editor.reset(ast) remains the explicit synchronization boundary: it maps conditions in the supplied effective AST back onto the stable slots and clears omitted slots. Successful changes continue through the existing filterastchange event.
Use the default builder preset for the complete nested AND/OR/NOT rule builder, or read-only to inspect a tree without mutation. Nested configuration can override structure, capabilities, presentation, workflow, and interaction independently; incompatible flat trees are rejected rather than silently flattened.
This surface reuses the grouped editor’s configured operators, selection sources, structured filter mounts, localization, validation, preview, and canonical apply path. It does not embed every complete classic per-column popup; reusable controls converge through the same lower-level filter contracts.
The mounted editor belongs to its application host and inherits the public --rv-filter-ast-editor-* variables defined there. Use surface-bg, surface-color, control-bg, text-color, and the corresponding control-border, divider, active-*, or muted-color variables to align it with the surrounding application theme. Unset variables use the editor’s standalone defaults.
For the underlying model and business case, read What Is a Filter AST?. For the complete API and operator contract, continue with Canonical Filter AST.