${totalRows === 0 && this._filterText
? html`
- No entries match "${this._filterText}"
+ ${this._noMatchMessage()}
`
: nothing}
@@ -3261,6 +3456,27 @@ export class DexTreeTable extends LitElement {
`;
}
+ private _renderColumnFilterPopup() {
+ const col = this._filterPopupCol;
+ if (!col) return nothing;
+ const existing = this._tokenForColumn(col);
+ return html`
+ {
+ this._filterPopupCol = null;
+ }}
+ >
+ `;
+ }
+
private _renderColumnMenu() {
if (!this._columnMenuOpen) return nothing;
return html`
diff --git a/src/webview/rowFilter.ts b/src/webview/rowFilter.ts
index f9b4b42..fdd49c6 100644
--- a/src/webview/rowFilter.ts
+++ b/src/webview/rowFilter.ts
@@ -37,6 +37,41 @@ export interface FilterableRow {
// another.
export type FilterTerm = { column: string | null; text: string };
+/** Every comparison the box understands. `~=` is normalized to `!=`. */
+export type FilterOp = 'contains' | '=' | '!=' | '>' | '<' | '>=' | '<=';
+
+/**
+ * One condition the user typed. Emitted alongside the predicates so the chip
+ * strip, the highlighter and the header popup all read the same answer.
+ */
+export interface FilterToken {
+ /** Exactly as typed, so Backspace can round-trip a chip back into the input. */
+ raw: string;
+ /** Span in the source text. Removing a chip is a splice, not a re-serialize. */
+ start: number;
+ end: number;
+ /** Resolved column key; null means "every visible column" (a bare term). */
+ column: string | null;
+ /** What the chip shows. Null for a bare term. */
+ columnLabel: string | null;
+ op: FilterOp;
+ /** Unquoted, as typed. Empty is legal: `Unit=` asks for empty cells. */
+ value: string;
+ /**
+ * Why this token is not doing what its text appears to ask.
+ * 'unknown-column' — `notacol:abc`; DOES filter, but as ordinary text
+ * 'non-numeric-bound' — `Value>abc`; contributes no predicate at all
+ */
+ warning?: 'unknown-column' | 'non-numeric-bound';
+}
+
+/** The columns a table has, and the label each one prints in its header. */
+export interface ColumnVocabulary {
+ labels: Record | null;
+ /** Every column the table has, visible or not. Null = do not restrict. */
+ keys: string[] | null;
+}
+
// Search prefixes that mean "substring-match this ONE column", and the column
// each names. `type:` deliberately reads DataType — the prefix is what the user
// types, the column is what the table calls it, and they are not the same word.
@@ -58,6 +93,102 @@ export const SUBSTRING_FILTER_COLUMNS = new Map([
['status', 'Status'],
]);
+// Header label (lowercased) → column key. Built per parse from the table's own
+// columns, so a prefix is always the name printed on the header the user is
+// looking at — and so a `.prj` table resolves `Type` to its own Type column
+// rather than to the dictionary's DataType.
+function buildLabelMap(vocab?: ColumnVocabulary): Map {
+ const map = new Map();
+ if (!vocab) return map;
+ const keys = vocab.keys ?? (vocab.labels ? Object.keys(vocab.labels) : []);
+ for (const key of keys) {
+ map.set((vocab.labels?.[key] ?? key).toLowerCase(), key);
+ }
+ return map;
+}
+
+// Resolves the text before an operator to a column, label first and legacy alias
+// second. A legacy alias must also EXIST in this table: `type:` means DataType in
+// a dictionary, and in a project table (Name/Type/Location/Labels) it must not
+// silently match nothing while naming a column that is on screen.
+function resolveColumn(prefix: string, labelMap: Map, vocab?: ColumnVocabulary): string | null {
+ const byLabel = labelMap.get(prefix);
+ if (byLabel) return byLabel;
+ const alias = prefix === 'value' ? 'Value' : SUBSTRING_FILTER_COLUMNS.get(prefix);
+ if (!alias) return null;
+ if (vocab?.keys && !vocab.keys.includes(alias)) return null;
+ return alias;
+}
+
+// The tokenizer keeps a "quoted phrase" together as ONE token specifically so its
+// spaces don't split it into separate terms; the quotes themselves are syntax, not
+// text to match, so they must come off before comparing. Leaving them on makes
+// every quoted search silently match nothing.
+function unquote(s: string): string {
+ return s.length > 1 && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s;
+}
+
+const OP_CHARS = new Set([':', '=', '<', '>', '!', '~']);
+
+interface OpHit {
+ /** Where the prefix ends, i.e. the operator's first character. */
+ prefixEnd: number;
+ op: FilterOp;
+ valueStart: number;
+}
+
+// Finds the operator inside ONE token, skipping anything inside quotes so that
+// `type:"Bus: myBus"` splits at its first colon and not at the one in the value.
+// Returns null when the token holds no operator at all — then the whole token is
+// ordinary text, which is also how `a>b` and `~foo` keep working.
+function findOperator(raw: string): OpHit | null {
+ let inQuote = false;
+ for (let i = 0; i < raw.length; i++) {
+ const ch = raw[i];
+ if (ch === '"') {
+ inQuote = !inQuote;
+ continue;
+ }
+ // A leading operator is not an empty prefix: `:abc` is text (and `:` alone
+ // must never resolve to a column, or every row would match).
+ if (inQuote || !OP_CHARS.has(ch) || i === 0) continue;
+
+ const two = raw.slice(i, i + 2);
+ if (two === '!=' || two === '~=') return { prefixEnd: i, op: '!=', valueStart: i + 2 };
+ if (two === '>=' || two === '<=') return { prefixEnd: i, op: two as FilterOp, valueStart: i + 2 };
+ // `!` and `~` mean nothing on their own — `Name!abc` is text.
+ if (ch === '!' || ch === '~') continue;
+ if (ch === '=') return { prefixEnd: i, op: '=', valueStart: i + 1 };
+ if (ch === '>' || ch === '<') return { prefixEnd: i, op: ch as FilterOp, valueStart: i + 1 };
+
+ // ':' — contains, unless the value opens with an operator. That is the legacy
+ // `value:>10` spelling, now accepted on every column.
+ const legacy = raw.slice(i + 1).match(/^(>=|<=|!=|~=|=|>|<)/);
+ if (legacy) {
+ const g = legacy[1];
+ return { prefixEnd: i, op: g === '~=' ? '!=' : (g as FilterOp), valueStart: i + 1 + g.length };
+ }
+ return { prefixEnd: i, op: 'contains', valueStart: i + 1 };
+ }
+ return null;
+}
+
+// `=` on a table whose cells are all strings. Numbers compare as numbers so that
+// `Value=10` finds a cell holding `10.0`; anything else compares as trimmed,
+// case-insensitive text, because one case rule for the whole box is worth more
+// than an exception nobody can see. An empty wanted value asks for empty cells,
+// which is the only way to ask "which entries have no Unit?".
+function valuesEqual(cell: string, wanted: string): boolean {
+ const c = cell.trim();
+ const w = wanted.trim();
+ if (c !== '' && w !== '') {
+ const cn = Number(c);
+ const wn = Number(w);
+ if (Number.isFinite(cn) && Number.isFinite(wn)) return cn === wn;
+ }
+ return c.toLowerCase() === w.toLowerCase();
+}
+
// Compiles the search box text into the row predicates AND the terms to
// highlight, in one pass over the tokens. Highlighting used to re-derive its
// term from the raw filter text, which held for a single word and broke for
@@ -70,92 +201,83 @@ export function parseFilterExpression(
text: string,
searchColumns: string[],
getCellText: (row: T, col: string) => string,
+ vocabulary?: ColumnVocabulary,
): {
+ tokens: FilterToken[];
predicates: Array<(row: T) => boolean>;
terms: FilterTerm[];
} {
+ const tokens: FilterToken[] = [];
const predicates: Array<(row: T) => boolean> = [];
const terms: FilterTerm[] = [];
- const tokens = text.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
-
- // The tokenizer keeps a "quoted phrase" together as ONE token specifically so
- // its spaces don't split it into separate terms; the quotes themselves are
- // syntax, not text to match, so they must come off before comparing. Leaving
- // them on makes every quoted search silently match nothing.
- const unquote = (s: string): string => (s.length > 1 && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s);
- // An EMPTY term must never be recorded. `name:` on its own is a half-typed
- // query that matches every row, but as a highlight term it would match at
- // every offset of every cell — and the scan advances by the term's length, so
- // a zero-length one never advances and hangs the webview mid-keystroke.
+ const labelMap = buildLabelMap(vocabulary);
+
+ // An EMPTY term must never be recorded: as a highlight term it would match at
+ // every offset of every cell, and the scan advances by the term's length, so a
+ // zero-length one never advances and hangs the webview mid-keystroke.
const addTerm = (column: string | null, term: string): void => {
if (term) terms.push({ column, text: term });
};
- const makeGenericPredicate = (term: string): ((row: T) => boolean) => {
- const lower = unquote(term).toLowerCase();
- addTerm(null, lower);
- return (row) => searchColumns.some((col) => getCellText(row, col).toLowerCase().includes(lower));
- };
- for (const token of tokens) {
- const colonIdx = token.indexOf(':');
- if (colonIdx > 0) {
- const prefix = token.slice(0, colonIdx).toLowerCase();
- const rawValue = token.slice(colonIdx + 1);
-
- const column = SUBSTRING_FILTER_COLUMNS.get(prefix);
- if (column) {
- const term = unquote(rawValue).toLowerCase();
- addTerm(column, term);
- predicates.push((row) => getCellText(row, column).toLowerCase().includes(term));
- } else if (prefix === 'value') {
- if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
- const exact = rawValue.slice(1, -1);
- addTerm('Value', exact.toLowerCase());
- predicates.push((row) => {
- return getCellText(row, 'Value') === exact;
- });
- } else if (/^(>=|<=|>|<|=)/.test(rawValue)) {
- const opMatch = rawValue.match(/^(>=|<=|>|<|=)/);
- const op = opMatch![0];
- const numStr = rawValue.slice(op.length);
- const num = parseFloat(numStr);
- if (!isNaN(num)) {
- predicates.push((row) => {
- const val = getCellText(row, 'Value');
- const rowNum = parseFloat(val);
- if (isNaN(rowNum)) return false;
- switch (op) {
- case '>':
- return rowNum > num;
- case '<':
- return rowNum < num;
- case '>=':
- return rowNum >= num;
- case '<=':
- return rowNum <= num;
- case '=':
- return rowNum === num;
- default:
- return false;
- }
- });
- }
- } else {
- const term = unquote(rawValue).toLowerCase();
- addTerm('Value', term);
- predicates.push((row) => {
- return getCellText(row, 'Value').toLowerCase().includes(term);
- });
- }
- } else {
- predicates.push(makeGenericPredicate(token));
- }
+ for (const m of text.matchAll(/(?:[^\s"]+|"[^"]*")+/g)) {
+ const raw = m[0];
+ const start = m.index;
+ const end = start + raw.length;
+ const hit = findOperator(raw);
+ const column = hit ? resolveColumn(unquote(raw.slice(0, hit.prefixEnd)).toLowerCase(), labelMap, vocabulary) : null;
+
+ // No operator, or a prefix that names no column: the whole token is text,
+ // colon included. `constructor:` is ordinary text a user may well look for.
+ if (!hit || !column) {
+ const value = unquote(raw);
+ const lower = value.toLowerCase();
+ tokens.push({
+ raw,
+ start,
+ end,
+ column: null,
+ columnLabel: null,
+ op: 'contains',
+ value,
+ ...(hit ? { warning: 'unknown-column' as const } : {}),
+ });
+ addTerm(null, lower);
+ predicates.push((row) => searchColumns.some((col) => getCellText(row, col).toLowerCase().includes(lower)));
+ continue;
+ }
+
+ const value = unquote(raw.slice(hit.valueStart));
+ const label = vocabulary?.labels?.[column] ?? column;
+ const token: FilterToken = { raw, start, end, column, columnLabel: label, op: hit.op, value };
+ tokens.push(token);
+
+ if (hit.op === 'contains') {
+ const lower = value.toLowerCase();
+ addTerm(column, lower);
+ predicates.push((row) => getCellText(row, column).toLowerCase().includes(lower));
+ } else if (hit.op === '=' || hit.op === '!=') {
+ // `=` highlights (its value IS in the cell); `!=` cannot — nothing matched.
+ if (hit.op === '=') addTerm(column, value.toLowerCase());
+ const want = hit.op === '=';
+ predicates.push((row) => valuesEqual(getCellText(row, column), value) === want);
} else {
- predicates.push(makeGenericPredicate(token));
+ // A bound that is not a number contributes NO predicate — a half-typed
+ // `Value>` must not blank the table. Surfaced on the chip instead.
+ const bound = parseFloat(value);
+ if (!Number.isFinite(bound)) {
+ token.warning = 'non-numeric-bound';
+ continue;
+ }
+ const op = hit.op;
+ predicates.push((row) => {
+ const n = parseFloat(getCellText(row, column));
+ if (!Number.isFinite(n)) return false;
+ return op === '>' ? n > bound : op === '<' ? n < bound : op === '>=' ? n >= bound : n <= bound;
+ });
}
}
- return { predicates, terms };
+ return { tokens, predicates, terms };
}
export function filterRows(
@@ -164,8 +286,9 @@ export function filterRows(
searchColumns: string[],
getCellText: (row: T, col: string) => string,
stickyRowIds: Set,
+ vocabulary?: ColumnVocabulary,
): T[] {
- const { predicates } = parseFilterExpression(text, searchColumns, getCellText);
+ const { predicates } = parseFilterExpression(text, searchColumns, getCellText, vocabulary);
if (predicates.length === 0) return rows;
const rowById = new Map();
@@ -229,3 +352,31 @@ export function filterRows(
return rows.filter((r) => includeSet.has(r.ID));
}
+
+// Quote only what has to be quoted: a space would otherwise split the token in
+// two, and a quote character would confuse the scanner's quote tracking.
+function quoteIfNeeded(s: string): string {
+ return /[\s"]/.test(s) ? `"${s.replace(/"/g, '')}"` : s;
+}
+
+/**
+ * The text one condition is spelled as. Used BOTH by the header popup's `writes:`
+ * preview and by the text it applies, so the preview cannot promise one thing and
+ * do another — and by nothing else, so there is one speller.
+ */
+export function formatToken(columnLabel: string, op: FilterOp, value: string): string {
+ const lhs = quoteIfNeeded(columnLabel);
+ const rhs = quoteIfNeeded(value);
+ return op === 'contains' ? `${lhs}:${rhs}` : `${lhs}${op}${rhs}`;
+}
+
+/**
+ * `text` with one token spliced out, closing the gap it leaves. Splices by SPAN
+ * rather than re-serializing the survivors, so a value's own spacing and quoting
+ * come through untouched.
+ */
+export function removeToken(text: string, token: FilterToken): string {
+ const before = text.slice(0, token.start).replace(/\s+$/, '');
+ const after = text.slice(token.end).replace(/^\s+/, '');
+ return before && after ? `${before} ${after}` : before || after;
+}
diff --git a/test/columnFilterPopup.test.ts b/test/columnFilterPopup.test.ts
new file mode 100644
index 0000000..34edd20
--- /dev/null
+++ b/test/columnFilterPopup.test.ts
@@ -0,0 +1,218 @@
+// Copyright 2026 The MathWorks, Inc.
+// @vitest-environment happy-dom
+//
+// The per-column filter popup. Its `writes:` line is the whole discoverability
+// mechanism — it teaches the search syntax by showing the exact text Apply is
+// about to put in the box — so every case here pins the two to the same string.
+// A preview that could drift from what Apply writes would teach a syntax the box
+// does not accept, which is worse than showing nothing at all.
+import { describe, it, expect, beforeEach } from 'vitest';
+import '../src/webview/components/dex-column-filter.js';
+import type { DexColumnFilter } from '../src/webview/components/dex-column-filter.js';
+import '../src/webview/components/dex-tree-table.js';
+import type { DexTreeTable, TreeTableRow } from '../src/webview/components/dex-tree-table.js';
+
+async function open(props: Partial = {}): Promise {
+ const el = document.createElement('dex-column-filter') as DexColumnFilter;
+ el.column = 'DataType';
+ el.columnLabel = 'Data Type';
+ Object.assign(el, props);
+ document.body.appendChild(el);
+ await el.updateComplete;
+ return el;
+}
+
+const $ = (el: DexColumnFilter, sel: string) => el.shadowRoot!.querySelector(sel) as HTMLElement;
+const writes = (el: DexColumnFilter) => $(el, '.writes-value').textContent!.trim();
+
+describe('dex-column-filter', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('titles itself with the column label and offers all seven operators', async () => {
+ const el = await open();
+ expect($(el, '.popup-title').textContent).toContain('Data Type');
+ const ops = [...el.shadowRoot!.querySelectorAll('.op-button')].map((b) => b.getAttribute('data-op'));
+ expect(ops).toEqual(['contains', '=', '!=', '>', '<', '>=', '<=']);
+ expect($(el, '.op-button[data-op="contains"]').getAttribute('aria-pressed')).toBe('true');
+ });
+
+ it('shows the exact text it will write, updating as the value is typed', async () => {
+ const el = await open();
+ expect(writes(el)).toBe('');
+ const input = $(el, '.popup-value') as HTMLInputElement;
+ input.value = 'double';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ await el.updateComplete;
+ expect(writes(el)).toBe('"Data Type":double');
+
+ $(el, '.op-button[data-op="!="]').click();
+ await el.updateComplete;
+ expect(writes(el)).toBe('"Data Type"!=double');
+ });
+
+ it('emits exactly what the writes line promised', async () => {
+ const el = await open({ op: '=', value: 'single' });
+ let detail: unknown = null;
+ el.addEventListener('dex-column-filter-applied', (e) => {
+ detail = (e as CustomEvent).detail;
+ });
+ const promised = writes(el);
+ $(el, '.popup-apply').click();
+ expect(detail).toEqual({ column: 'DataType', op: '=', value: 'single', text: promised });
+ });
+
+ it('opens on the operator and value it was prefilled with', async () => {
+ const el = await open({ op: '>', value: '10' });
+ expect(($(el, '.popup-value') as HTMLInputElement).value).toBe('10');
+ expect($(el, '.op-button[data-op=">"]').getAttribute('aria-pressed')).toBe('true');
+ });
+
+ it('Enter applies and Escape closes without applying', async () => {
+ const el = await open({ value: 'x' });
+ const seen: string[] = [];
+ el.addEventListener('dex-column-filter-applied', () => seen.push('applied'));
+ el.addEventListener('dex-column-filter-closed', () => seen.push('closed'));
+ const input = $(el, '.popup-value');
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ expect(seen).toEqual(['closed']);
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ expect(seen).toEqual(['closed', 'applied']);
+ });
+
+ it('Clear asks for the column condition to be removed', async () => {
+ const el = await open({ op: '=', value: 'single', hasExisting: true });
+ let detail: unknown = null;
+ el.addEventListener('dex-column-filter-cleared', (e) => {
+ detail = (e as CustomEvent).detail;
+ });
+ $(el, '.popup-clear').click();
+ expect(detail).toEqual({ column: 'DataType' });
+ });
+
+ it('applies an empty value, because Unit= is a real question', async () => {
+ const el = await open({ op: '=', value: '' });
+ let detail: { text?: string } | null = null;
+ el.addEventListener('dex-column-filter-applied', (e) => {
+ detail = (e as CustomEvent).detail;
+ });
+ $(el, '.popup-apply').click();
+ expect(detail!.text).toBe('"Data Type"=');
+ });
+});
+
+describe('the table and the popup together', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ async function table(): Promise {
+ const el = document.createElement('dex-tree-table') as DexTreeTable;
+ el.columns = ['Name', 'DataType', 'Value'];
+ el.columnLabels = { Name: 'Name', DataType: 'Data Type', Value: 'Value' };
+ // One row, because the empty-rows branch renders "No data" instead of a header
+ // row — and the headers are what this block right-clicks on.
+ el.rows = [{ ID: 'a', parent: null, Name: { label: 'gain' }, Value: '5', DataType: 'double' } as TreeTableRow];
+ document.body.appendChild(el);
+ await el.updateComplete;
+ (el as any)._hiddenColumns = new Set();
+ el.requestUpdate();
+ await el.updateComplete;
+ return el;
+ }
+
+ // By label, not by index: the header order is the table's own default column
+ // order, not the order `columns` was handed in.
+ const headerFor = (el: DexTreeTable, label: string): HTMLElement =>
+ [...el.shadowRoot!.querySelectorAll('th')].find(
+ (th) => th.querySelector('.th-label')?.textContent?.trim() === label,
+ ) as HTMLElement;
+
+ const popupOf = (el: DexTreeTable) =>
+ el.shadowRoot!.querySelector('dex-column-filter') as DexColumnFilter | null;
+
+ it('a right-click on a header opens the popup for that column', async () => {
+ const el = await table();
+ headerFor(el, 'Data Type').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true }));
+ await el.updateComplete;
+ expect(popupOf(el)!.column).toBe('DataType');
+ });
+
+ it('Apply puts the popup text in the search box', async () => {
+ const el = await table();
+ (el as any)._openColumnFilter('DataType', el.shadowRoot!.querySelector('th')!);
+ await el.updateComplete;
+ popupOf(el)!.value = 'double';
+ await popupOf(el)!.updateComplete;
+ (popupOf(el)!.shadowRoot!.querySelector('.popup-apply') as HTMLElement).click();
+ await el.updateComplete;
+ expect((el as any)._filterText).toBe('"Data Type":double');
+ expect(popupOf(el)).toBeNull();
+ });
+
+ it('the search bar shows the condition the popup wrote, as a chip', async () => {
+ // Apply that filters without updating the bar would leave the user looking at a
+ // table narrowed by a condition they cannot see, edit, or clear. The bar shows
+ // it as a chip rather than as raw text now, so the chip is what this reads —
+ // spelled from the same label and value the popup applied.
+ const el = await table();
+ (el as any)._applyColumnFilter('DataType', 'contains', 'double');
+ await el.updateComplete;
+ const bar = el.shadowRoot!.querySelector('dex-filter-bar') as HTMLElement & {
+ updateComplete: Promise;
+ };
+ await bar.updateComplete;
+ const chip = bar.shadowRoot!.querySelector('.chip') as HTMLElement;
+ expect(chip.querySelector('.chip-label')!.textContent).toBe('Data Type');
+ expect(chip.querySelector('.chip-value')!.textContent).toBe('double');
+ expect((el as any)._filterText).toBe('"Data Type":double');
+ });
+
+ it('applying twice on one column replaces its condition rather than stacking', async () => {
+ const el = await table();
+ (el as any)._applyColumnFilter('DataType', 'contains', 'double');
+ (el as any)._applyColumnFilter('DataType', '=', 'single');
+ await el.updateComplete;
+ expect((el as any)._filterText).toBe('"Data Type"=single');
+ });
+
+ it('keeps the other conditions when it replaces one', async () => {
+ const el = await table();
+ (el as any)._filterText = 'gain Value>1';
+ (el as any)._applyColumnFilter('DataType', '=', 'single');
+ await el.updateComplete;
+ expect((el as any)._filterText).toBe('gain Value>1 "Data Type"=single');
+ (el as any)._applyColumnFilter('Value', '<', '9');
+ await el.updateComplete;
+ expect((el as any)._filterText).toBe('gain Value<9 "Data Type"=single');
+ });
+
+ it('Clear removes only that column condition', async () => {
+ const el = await table();
+ (el as any)._filterText = 'gain "Data Type"=single Value>1';
+ (el as any)._clearColumnFilter('DataType');
+ await el.updateComplete;
+ expect((el as any)._filterText).toBe('gain Value>1');
+ });
+
+ it('the funnel is solid only on the columns that actually have a condition', async () => {
+ const el = await table();
+ (el as any)._applyColumnFilter('DataType', '=', 'single');
+ await el.updateComplete;
+ const funnel = (label: string) => headerFor(el, label).querySelector('.th-filter') as HTMLElement;
+ expect(funnel('Data Type').classList.contains('active')).toBe(true);
+ expect(funnel('Value').classList.contains('active')).toBe(false);
+ });
+
+ it('a funnel click opens the popup without sorting the column', async () => {
+ // The funnel sits inside the , whose own click handler sorts. Reordering the
+ // table under the user as they reach for a filter is the bug this pins.
+ const el = await table();
+ const funnel = headerFor(el, 'Data Type').querySelector('.th-filter') as HTMLElement;
+ funnel.click();
+ await el.updateComplete;
+ expect(popupOf(el)!.column).toBe('DataType');
+ expect((el as any)._sortState).toEqual([]);
+ });
+});
diff --git a/test/filterBar.test.ts b/test/filterBar.test.ts
new file mode 100644
index 0000000..6a3e5b3
--- /dev/null
+++ b/test/filterBar.test.ts
@@ -0,0 +1,230 @@
+// Copyright 2026 The MathWorks, Inc.
+// @vitest-environment happy-dom
+import { describe, it, expect, beforeEach } from 'vitest';
+import '../src/webview/components/dex-filter-bar.js';
+import type { DexFilterBar } from '../src/webview/components/dex-filter-bar.js';
+import { parseFilterExpression } from '../src/webview/rowFilter.js';
+
+const COLUMNS = ['Name', 'Value', 'DataType'];
+const VOCAB = { labels: { Name: 'Name', Value: 'Value', DataType: 'Data Type' }, keys: COLUMNS };
+
+/** A bar showing `text`, parsed exactly as the table parses it. */
+async function bar(text = ''): Promise {
+ const el = document.createElement('dex-filter-bar') as DexFilterBar;
+ el.text = text;
+ el.tokens = parseFilterExpression(text, COLUMNS, () => '', VOCAB).tokens;
+ document.body.appendChild(el);
+ await el.updateComplete;
+ return el;
+}
+
+const input = (el: DexFilterBar) => el.shadowRoot!.querySelector('.filter-input') as HTMLInputElement;
+const chips = (el: DexFilterBar) => [...el.shadowRoot!.querySelectorAll('.chip')] as HTMLElement[];
+
+/** Type into the tail without committing it. */
+async function type(el: DexFilterBar, value: string): Promise {
+ input(el).value = value;
+ input(el).dispatchEvent(new Event('input', { bubbles: true }));
+ await el.updateComplete;
+}
+
+async function press(el: DexFilterBar, key: string): Promise {
+ input(el).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
+ await el.updateComplete;
+}
+
+function applied(el: DexFilterBar): string[] {
+ const seen: string[] = [];
+ el.addEventListener('dex-filter-applied', (e) => seen.push((e as CustomEvent).detail.text));
+ return seen;
+}
+
+describe('dex-filter-bar', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('renders one chip per condition, in order', async () => {
+ const el = await bar('abc Name:gain Value>10');
+ expect(chips(el).map((c) => c.textContent!.replace(/\s+/g, ' ').trim())).toEqual([
+ 'abc ×',
+ 'Name : gain ×',
+ 'Value > 10 ×',
+ ]);
+ });
+
+ it('keeps the column label and the typed value as separate elements', async () => {
+ // Pinned so the three-tier typography cannot be lost to a refactor: the label is
+ // a quiet 11px grey, the value is content, and the operator carries the meaning.
+ const el = await bar('"Data Type"!=double');
+ const chip = chips(el)[0];
+ expect(chip.querySelector('.chip-label')!.textContent).toBe('Data Type');
+ expect(chip.querySelector('.chip-op')!.textContent!.trim()).toBe('≠');
+ expect(chip.querySelector('.chip-value')!.textContent).toBe('double');
+ });
+
+ it('a bare term has no label and no operator glyph', async () => {
+ const el = await bar('gain');
+ const chip = chips(el)[0];
+ expect(chip.querySelector('.chip-label')).toBeNull();
+ expect(chip.querySelector('.chip-op')).toBeNull();
+ expect(chip.querySelector('.chip-value')!.textContent).toBe('gain');
+ expect(chip.className).toContain('bare');
+ });
+
+ it('tints a column chip differently from a bare one', async () => {
+ const el = await bar('gain Name:gain');
+ expect(chips(el)[0].className).toContain('bare');
+ expect(chips(el)[1].className).toContain('column');
+ });
+
+ it('marks a warning chip and says why in its tooltip', async () => {
+ const el = await bar('Value>abc');
+ const chip = chips(el)[0];
+ expect(chip.className).toContain('warning');
+ expect(chip.title).toContain('not a number');
+ });
+
+ it('shows ≥ and ≤ rather than their ASCII spellings', async () => {
+ const el = await bar('Value>=1 Value<=9');
+ expect(chips(el).map((c) => c.querySelector('.chip-op')!.textContent!.trim())).toEqual(['≥', '≤']);
+ });
+
+ it('exposes the chip strip as a list with a labelled remove button each', async () => {
+ const el = await bar('Name:gain');
+ expect(el.shadowRoot!.querySelector('.chip-strip')!.getAttribute('role')).toBe('list');
+ expect(chips(el)[0].getAttribute('role')).toBe('listitem');
+ const remove = chips(el)[0].querySelector('.chip-remove')!;
+ expect(remove.tagName).toBe('BUTTON');
+ expect(remove.getAttribute('aria-label')).toBe('Remove filter Name contains gain');
+ });
+
+ it('removing a chip proposes the text without it', async () => {
+ const el = await bar('abc Name:gain Value>10');
+ const seen = applied(el);
+ (chips(el)[1].querySelector('.chip-remove') as HTMLElement).click();
+ expect(seen).toEqual(['abc Value>10']);
+ });
+});
+
+describe('dex-filter-bar keyboard', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('typing proposes nothing; Enter proposes', async () => {
+ const el = await bar();
+ const seen = applied(el);
+ await type(el, 'gain');
+ expect(seen).toEqual([]);
+ await press(el, 'Enter');
+ expect(seen).toEqual(['gain']);
+ });
+
+ it('appends the tail to what is already applied and clears the tail', async () => {
+ const el = await bar('abc');
+ const seen = applied(el);
+ await type(el, 'Name:gain');
+ await press(el, 'Enter');
+ expect(seen).toEqual(['abc Name:gain']);
+ expect(input(el).value).toBe('');
+ });
+
+ it('one Enter can commit several conditions at once', async () => {
+ const el = await bar();
+ const seen = applied(el);
+ await type(el, 'Name:a Value>1');
+ await press(el, 'Enter');
+ // Two chips once the table re-parses; here, one proposal holding both.
+ expect(seen).toEqual(['Name:a Value>1']);
+ });
+
+ it('Enter on an empty tail proposes nothing', async () => {
+ const el = await bar('abc');
+ const seen = applied(el);
+ await press(el, 'Enter');
+ expect(seen).toEqual([]);
+ });
+
+ it('shows the ⏎ hint only while a tail is pending', async () => {
+ const el = await bar();
+ expect(el.shadowRoot!.querySelector('.pending-hint')).toBeNull();
+ await type(el, 'ga');
+ expect(el.shadowRoot!.querySelector('.pending-hint')!.textContent).toContain('to filter');
+ await press(el, 'Enter');
+ expect(el.shadowRoot!.querySelector('.pending-hint')).toBeNull();
+ });
+
+ it('Backspace at the start of an empty tail pops the last chip back as text', async () => {
+ const el = await bar('abc Name~=gain');
+ const seen = applied(el);
+ input(el).setSelectionRange(0, 0);
+ await press(el, 'Backspace');
+ // The user's own spelling comes back, `~=` and all — not the normalized `!=`.
+ expect(input(el).value).toBe('Name~=gain');
+ expect(seen).toEqual(['abc']);
+ });
+
+ it('Backspace with a tail present deletes text, not a chip', async () => {
+ const el = await bar('abc');
+ const seen = applied(el);
+ await type(el, 'g');
+ input(el).setSelectionRange(1, 1);
+ await press(el, 'Backspace');
+ expect(seen).toEqual([]);
+ });
+
+ it('Escape clears a pending tail first and the filter second', async () => {
+ const el = await bar('abc');
+ const seen = applied(el);
+ await type(el, 'gain');
+ await press(el, 'Escape');
+ expect(input(el).value).toBe('');
+ expect(seen).toEqual([]);
+ await press(el, 'Escape');
+ expect(seen).toEqual(['']);
+ });
+});
+
+describe('dex-filter-bar leaves the browser its own keys', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('does not preventDefault on ordinary typing, arrows or select-all', async () => {
+ const el = await bar('abc');
+ await type(el, 'gain');
+ for (const key of ['a', 'ArrowLeft', 'ArrowRight', 'Home', 'End']) {
+ const e = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
+ input(el).dispatchEvent(e);
+ expect(e.defaultPrevented, key).toBe(false);
+ }
+ });
+
+ it('leaves ArrowLeft at position 0 to the browser rather than eating a chip', async () => {
+ const el = await bar('abc Name:gain');
+ const seen = applied(el);
+ input(el).setSelectionRange(0, 0);
+ const e = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, cancelable: true });
+ input(el).dispatchEvent(e);
+ expect(e.defaultPrevented).toBe(false);
+ expect(seen).toEqual([]);
+ });
+
+ it('pastes a whole query as one tail, committed by one Enter', async () => {
+ const el = await bar();
+ const seen = applied(el);
+ await type(el, 'Name:a "Data Type"=double Value>1');
+ await press(el, 'Enter');
+ expect(seen).toEqual(['Name:a "Data Type"=double Value>1']);
+ });
+
+ it('Backspace on a selection deletes the selection, not a chip', async () => {
+ const el = await bar('abc');
+ const seen = applied(el);
+ await type(el, 'gain');
+ input(el).setSelectionRange(0, 4);
+ await press(el, 'Backspace');
+ expect(seen).toEqual([]);
+ });
+});
diff --git a/test/rowFilter.test.ts b/test/rowFilter.test.ts
index 6ecf15e..da5a702 100644
--- a/test/rowFilter.test.ts
+++ b/test/rowFilter.test.ts
@@ -6,7 +6,9 @@
// unchanged — these tests exist to reach the grammar's edge cases without paying
// for a happy-dom component mount per case, and to cover the module in isolation.
import { describe, it, expect } from 'vitest';
-import { parseFilterExpression, filterRows, SUBSTRING_FILTER_COLUMNS } from '../src/webview/rowFilter.js';
+import {
+ parseFilterExpression, filterRows, formatToken, removeToken, SUBSTRING_FILTER_COLUMNS,
+} from '../src/webview/rowFilter.js';
interface Row {
ID: string;
@@ -109,14 +111,19 @@ describe('parseFilterExpression', () => {
expect(predicates[0](row('a', null, { Value: '5' }))).toBe(true);
});
- it('value:"..." is an exact match, distinct from the substring form', () => {
- const { predicates: exact } = parseFilterExpression('value:"5"', COLUMNS, getCellText);
+ it('a quoted value groups, it does not mean exact — = is what does', () => {
+ // The one grammar exception that used to live here: `value:"5"` was exact AND
+ // case-sensitive while its five sibling prefixes were neither. Now quoting only
+ // holds a phrase together, and `=` is the operator that means exactly.
+ const { predicates: quoted } = parseFilterExpression('value:"5"', COLUMNS, getCellText);
+ expect(quoted[0](row('a', null, { Value: '15' }))).toBe(true);
+
+ const VOCAB = { labels: { Value: 'Value' }, keys: COLUMNS };
+ const { predicates: exact } = parseFilterExpression('Value=5', COLUMNS, getCellText, VOCAB);
expect(exact[0](row('a', null, { Value: '5' }))).toBe(true);
expect(exact[0](row('a', null, { Value: '15' }))).toBe(false);
- expect(exact[0](row('a', null, { Value: '5.0' }))).toBe(false);
-
- const { predicates: sub } = parseFilterExpression('value:5', COLUMNS, getCellText);
- expect(sub[0](row('a', null, { Value: '15' }))).toBe(true);
+ // Numeric, so a differently-spelled 5 still counts.
+ expect(exact[0](row('a', null, { Value: '5.0' }))).toBe(true);
});
it('col: prefixes resolve through SUBSTRING_FILTER_COLUMNS, case-insensitively', () => {
@@ -230,3 +237,115 @@ describe('filterRows', () => {
expect(ids(rows, 'gain', new Set())).toEqual(['a']);
});
});
+
+describe('the operator scanner', () => {
+ const VOCAB = { labels: { Name: 'Name', Value: 'Value', DataType: 'Data Type' }, keys: COLUMNS };
+
+ it('reads a header label as the prefix, quoted when it has a space', () => {
+ const { tokens } = parseFilterExpression('"Data Type"=double', COLUMNS, getCellText, VOCAB);
+ expect(tokens).toHaveLength(1);
+ expect(tokens[0].column).toBe('DataType');
+ expect(tokens[0].op).toBe('=');
+ expect(tokens[0].value).toBe('double');
+ });
+
+ it('normalizes ~= to != while keeping the raw text the user typed', () => {
+ const { tokens } = parseFilterExpression('Name~=abc', COLUMNS, getCellText, VOCAB);
+ expect(tokens[0].op).toBe('!=');
+ expect(tokens[0].raw).toBe('Name~=abc');
+ });
+
+ it('reads the two-character operators before the one-character ones', () => {
+ for (const [text, op] of [['Value>=1', '>='], ['Value<=1', '<='], ['Value!=1', '!=']] as const) {
+ expect(parseFilterExpression(text, COLUMNS, getCellText, VOCAB).tokens[0].op).toBe(op);
+ }
+ });
+
+ it('still accepts the legacy colon-then-operator form on any column', () => {
+ const { tokens } = parseFilterExpression('Value:>10', COLUMNS, getCellText, VOCAB);
+ expect(tokens[0].op).toBe('>');
+ expect(tokens[0].value).toBe('10');
+ });
+
+ it('leaves a bare word containing an operator character as ordinary text', () => {
+ const { tokens } = parseFilterExpression('a>b', COLUMNS, getCellText, VOCAB);
+ expect(tokens[0].column).toBeNull();
+ expect(tokens[0].op).toBe('contains');
+ expect(tokens[0].value).toBe('a>b');
+ });
+
+ it('does not read a lone ! or ~ as an operator', () => {
+ expect(parseFilterExpression('Name!abc', COLUMNS, getCellText, VOCAB).tokens[0].column).toBeNull();
+ expect(parseFilterExpression('~abc', COLUMNS, getCellText, VOCAB).tokens[0].value).toBe('~abc');
+ });
+
+ it('records the span so removing a token is a splice', () => {
+ const text = 'abc Name=x Value>1';
+ const { tokens } = parseFilterExpression(text, COLUMNS, getCellText, VOCAB);
+ expect(tokens.map((t) => text.slice(t.start, t.end))).toEqual(['abc', 'Name=x', 'Value>1']);
+ });
+});
+
+describe('the = rule', () => {
+ const VOCAB = { labels: { Name: 'Name', Value: 'Value' }, keys: COLUMNS };
+ const match = (text: string, r: Row) =>
+ parseFilterExpression(text, COLUMNS, getCellText, VOCAB).predicates.every((p) => p(r));
+
+ it('compares as numbers when both sides are numbers, so 10 equals 10.0', () => {
+ expect(match('Value=10', row('a', null, { Value: '10.0' }))).toBe(true);
+ expect(match('Value=10', row('a', null, { Value: '1e1' }))).toBe(true);
+ expect(match('Value=10', row('a', null, { Value: '100' }))).toBe(false);
+ });
+
+ it('compares as text, case-insensitively, when either side is not a number', () => {
+ expect(match('Name=MYVAR', row('a', null, { Name: 'myVar' }))).toBe(true);
+ expect(match('Name=myVa', row('a', null, { Name: 'myVar' }))).toBe(false);
+ });
+
+ it('an empty value asks for empty cells', () => {
+ expect(match('Value=', row('a', null, { Value: '' }))).toBe(true);
+ expect(match('Value=', row('a', null, { Value: '0' }))).toBe(false);
+ });
+
+ it('!= includes a row whose cell is empty', () => {
+ expect(match('Value!=5', row('a', null, { Value: '' }))).toBe(true);
+ expect(match('Value!=5', row('a', null, { Value: '5' }))).toBe(false);
+ expect(match('Value~=5', row('a', null, { Value: '5' }))).toBe(false);
+ });
+
+ it('a comparison with a non-numeric bound is ignored, not empty-matching', () => {
+ const { predicates, tokens } = parseFilterExpression('Value>abc', COLUMNS, getCellText, VOCAB);
+ expect(predicates).toEqual([]);
+ expect(tokens[0].warning).toBe('non-numeric-bound');
+ });
+});
+
+describe('formatToken and removeToken', () => {
+ it('quotes a label or value only when it needs quoting', () => {
+ expect(formatToken('Name', 'contains', 'abc')).toBe('Name:abc');
+ expect(formatToken('Data Type', '=', 'double')).toBe('"Data Type"=double');
+ expect(formatToken('Name', '=', 'my var')).toBe('Name="my var"');
+ expect(formatToken('Value', '>', '10')).toBe('Value>10');
+ });
+
+ it('round-trips through the parser to the same column, op and value', () => {
+ const VOCAB = { labels: { DataType: 'Data Type' }, keys: ['DataType'] };
+ const text = formatToken('Data Type', '!=', 'my type');
+ const { tokens } = parseFilterExpression(text, ['DataType'], getCellText, VOCAB);
+ expect(tokens[0]).toMatchObject({ column: 'DataType', op: '!=', value: 'my type' });
+ });
+
+ it('removes one token and leaves the rest re-parsing unchanged', () => {
+ const text = 'abc Name=x Value>1';
+ const { tokens } = parseFilterExpression(text, COLUMNS, getCellText);
+ expect(removeToken(text, tokens[1])).toBe('abc Value>1');
+ expect(removeToken(text, tokens[0])).toBe('Name=x Value>1');
+ expect(removeToken(text, tokens[2])).toBe('abc Name=x');
+ });
+
+ it('does not disturb whitespace inside a quoted value', () => {
+ const text = 'Name="my var" abc';
+ const { tokens } = parseFilterExpression(text, COLUMNS, getCellText);
+ expect(removeToken(text, tokens[1])).toBe('Name="my var"');
+ });
+});
diff --git a/test/treeTableCells.test.ts b/test/treeTableCells.test.ts
index 7d79382..4dede87 100644
--- a/test/treeTableCells.test.ts
+++ b/test/treeTableCells.test.ts
@@ -33,6 +33,18 @@ async function mount(rows: TreeTableRow[]): Promise {
const cell = (table: DexTreeTable, rowId: string, col: string): HTMLElement =>
table.shadowRoot!.querySelector(`tr[data-row-id="${rowId}"] td.col-${col}`) as HTMLElement;
+// Search through the real box, which lives inside the chip bar's shadow root and
+// commits on Enter — typing alone no longer filters.
+async function search(table: DexTreeTable, query: string): Promise {
+ const bar = table.shadowRoot!.querySelector('dex-filter-bar') as HTMLElement & { updateComplete: Promise };
+ const input = bar.shadowRoot!.querySelector('.filter-input') as HTMLInputElement;
+ input.value = query;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
+ await bar.updateComplete;
+ await table.updateComplete;
+}
+
const text = (table: DexTreeTable, rowId: string, col: string): string =>
(cell(table, rowId, col).textContent || '').trim();
@@ -68,10 +80,7 @@ describe('user-supplied text is rendered as text, never as markup', () => {
// pieces have to stay escaped or a crafted name becomes markup as soon as the
// user searches for part of it.
const table = await mount([makeRow('x', ' ')]);
- const input = table.shadowRoot!.querySelector('.filter-input') as HTMLInputElement;
- input.value = 'img';
- input.dispatchEvent(new Event('input', { bubbles: true }));
- await table.updateComplete;
+ await search(table, 'img');
expect(table.shadowRoot!.querySelectorAll('img').length).toBe(0);
expect(cell(table, 'x', 'Name').querySelector('mark')!.textContent).toBe('img');
@@ -417,10 +426,7 @@ describe('links navigate rather than following an href', () => {
it('is still highlighted by a search that matches it', async () => {
// Highlighting is about finding the text, which is there whether or not it links.
const table = await mount([makeRow('u', 'u', { UsedBy: { paramLinks: [UNRESOLVED] } as any })]);
- const input = table.shadowRoot!.querySelector('.filter-input') as HTMLInputElement;
- input.value = 'final';
- input.dispatchEvent(new Event('input', { bubbles: true }));
- await table.updateComplete;
+ await search(table, 'final');
expect(cell(table, 'u', 'UsedBy').querySelector('mark')!.textContent).toBe('final');
table.remove();
});
@@ -803,10 +809,7 @@ describe('cell text for sorting and searching', () => {
makeRow('a', 'A', { DataType: { links: [{ text: 'myBus', linkTarget: 'x' }] } as any }),
makeRow('b', 'B', { DataType: 'double' }),
]);
- const input = table.shadowRoot!.querySelector('.filter-input') as HTMLInputElement;
- input.value = 'type:myBus';
- input.dispatchEvent(new Event('input', { bubbles: true }));
- await table.updateComplete;
+ await search(table, 'type:myBus');
expect(
Array.from(table.shadowRoot!.querySelectorAll('tr.data-row')).map((r) => r.getAttribute('data-row-id')),
).toEqual(['a']);
@@ -1212,10 +1215,7 @@ describe('a Data Type that names a type definition', () => {
makeRow('s', 'sig', { DataType: QUALIFIED as any }),
makeRow('p', 'param', { DataType: { text: 'adtUint8', linkTarget: 'adtUint8@d.sldd' } as any }),
]);
- const input = table.shadowRoot!.querySelector('.filter-input') as HTMLInputElement;
- input.value = 'type:bus';
- input.dispatchEvent(new Event('input', { bubbles: true }));
- await table.updateComplete;
+ await search(table, 'type:bus');
expect(cell(table, 's', 'DataType'), 'the bus-typed row survives type:bus').not.toBeNull();
expect(table.shadowRoot!.querySelector('tr[data-row-id="p"]'), 'the uint8-typed row does not').toBeNull();
diff --git a/test/treeTableFilter.test.ts b/test/treeTableFilter.test.ts
index 71a37b6..cc0d0b7 100644
--- a/test/treeTableFilter.test.ts
+++ b/test/treeTableFilter.test.ts
@@ -9,6 +9,7 @@
// is useless if its parent rows vanish.
import { describe, it, expect, beforeEach } from 'vitest';
import { DexTreeTable, type TreeTableRow } from '../src/webview/components/dex-tree-table.js';
+import type { DexFilterBar } from '../src/webview/components/dex-filter-bar.js';
import { nextStickyIds } from '../src/webview/rowUpdates.js';
const HOST_COLUMNS = ['Name', 'Value', 'DataType', 'UsedBy', 'Status', 'Kind', 'Class'];
@@ -34,11 +35,29 @@ async function mount(rows: TreeTableRow[], expanded: string[] = []): Promise
+ barOf(table).shadowRoot!.querySelector('.filter-input') as HTMLInputElement;
+
+const barOf = (table: DexTreeTable): DexFilterBar =>
+ table.shadowRoot!.querySelector('dex-filter-bar') as DexFilterBar;
+
+/**
+ * Ask one filter question and read back what matched. Each call is a FRESH
+ * question — it resets the applied text first — because the bar now APPENDS a
+ * committed tail rather than replacing the box, and every case below was written
+ * against a box that replaced. Typed into the real input and committed with a real
+ * Enter, so the tail binding, the propose event and the cache invalidation all run.
+ */
async function search(table: DexTreeTable, text: string): Promise {
- const input = table.shadowRoot!.querySelector('.filter-input') as HTMLInputElement;
+ const bar = barOf(table);
+ (table as any)._setFilterText('');
+ await table.updateComplete;
+ const input = boxOf(table);
input.value = text;
input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
+ await bar.updateComplete;
await table.updateComplete;
return (table as any)._getVisibleRows().map((r: TreeTableRow) => r.ID);
}
@@ -234,14 +253,19 @@ describe('value: searches', () => {
table.remove();
});
- it('value:"..." is exact, so it does not match a longer value', async () => {
- // Distinguishing 5 from 15 and 100 is the whole point of the quoted form.
+ it('a quoted value groups, it does not mean exact — = is what does', async () => {
+ // The one grammar exception that used to live here: `value:"5"` was exact AND
+ // case-sensitive while its five sibling prefixes were neither, so which of two
+ // adjacent boxes the user typed in decided what quotes meant. Now quoting only
+ // holds a phrase together, and `=` is the operator that means exactly — read
+ // numerically, so the 5 spelled `5.0` is the same 5.
const table = await mount([
makeRow('a', null, 'a', { Value: '5' }),
makeRow('b', null, 'b', { Value: '15' }),
makeRow('c', null, 'c', { Value: '5.0' }),
]);
- expect(await search(table, 'value:"5"')).toEqual(['a']);
+ expect(await search(table, 'value:"5"')).toEqual(['a', 'b', 'c']);
+ expect(await search(table, 'Value=5')).toEqual(['a', 'c']);
table.remove();
});
@@ -335,25 +359,34 @@ describe('a filtered view stays a usable tree', () => {
});
describe('the search box itself', () => {
- it('Escape clears the box and the filter together', async () => {
- // Clearing only one of the two would leave the table filtered by text the
- // user can no longer see.
+ it('Escape clears a pending tail first, then the filter', async () => {
const table = await mount(CATALOG);
- await search(table, 'gain');
- const input = table.shadowRoot!.querySelector('.filter-input') as HTMLInputElement;
- input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ const bar = barOf(table);
+ const input = boxOf(table);
+ expect(await search(table, 'gain')).toEqual(['p1']);
+
+ input.value = 'half';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ await bar.updateComplete;
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
+ await bar.updateComplete;
await table.updateComplete;
+ // First press abandons the half-typed word; the applied filter survives it.
expect(input.value).toBe('');
+ expect((table as any)._filterText).toBe('gain');
+
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
+ await bar.updateComplete;
+ await table.updateComplete;
expect((table as any)._filterText).toBe('');
- expect((table as any)._getVisibleRows().length).toBe(4);
+ expect((table as any)._getVisibleRows().length).toBeGreaterThan(1);
table.remove();
});
it('a key other than Escape leaves the filter alone', async () => {
const table = await mount(CATALOG);
await search(table, 'gain');
- const input = table.shadowRoot!.querySelector('.filter-input') as HTMLInputElement;
- input.dispatchEvent(new KeyboardEvent('keydown', { key: 'x', bubbles: true }));
+ boxOf(table).dispatchEvent(new KeyboardEvent('keydown', { key: 'x', bubbles: true }));
await table.updateComplete;
expect((table as any)._filterText).toBe('gain');
table.remove();
@@ -365,15 +398,28 @@ describe('the search box itself', () => {
table.remove();
});
- it('focusFilter selects the existing text so the next keystroke replaces it', async () => {
+ it('focusFilter selects what is in the box so the next keystroke replaces it', async () => {
// This backs the Ctrl+F shortcut; without .select() the user types into the
- // middle of their previous query.
- const table = await mount(CATALOG);
- await search(table, 'gain');
- const input = table.shadowRoot!.querySelector('.filter-input') as HTMLInputElement;
+ // middle of what they had half-typed. What is IN the box is now the uncommitted
+ // tail — the applied conditions are chips beside it, and Ctrl+F must not select
+ // those, because a keystroke cannot replace a chip.
+ const table = await mount(CATALOG);
+ const bar = barOf(table);
+ const input = boxOf(table);
+ input.value = 'gai';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ await bar.updateComplete;
table.focusFilter();
expect(input.selectionStart).toBe(0);
- expect(input.selectionEnd).toBe('gain'.length);
+ expect(input.selectionEnd).toBe('gai'.length);
+ table.remove();
+ });
+
+ it('focusFilter reaches the input inside the bar', async () => {
+ const table = await mount(CATALOG);
+ table.focusFilter();
+ const bar = barOf(table);
+ expect(bar.shadowRoot!.activeElement).toBe(bar.shadowRoot!.querySelector('.filter-input'));
table.remove();
});
@@ -381,10 +427,27 @@ describe('the search box itself', () => {
// The empty-state render is a separate branch; dropping the box there would
// trap a user who filtered a small file down to nothing.
const table = await mount([]);
- expect(table.shadowRoot!.querySelector('.filter-input')).not.toBeNull();
+ expect(boxOf(table)).not.toBeNull();
expect(table.shadowRoot!.querySelector('.empty-state')!.textContent).toContain('No data');
table.remove();
});
+
+ it('keeps the same bar element across the loading to loaded repaint', async () => {
+ // The bar is rendered from ONE call site so Lit reuses it; a per-state literal
+ // rebuilt the input and dropped focus and anything typed while the file parsed.
+ const table = new DexTreeTable();
+ table.columns = HOST_COLUMNS;
+ document.body.appendChild(table);
+ await table.updateComplete;
+ const before = table.shadowRoot!.querySelector('dex-filter-bar');
+ // Guarded, or two nulls would satisfy the identity check below and this would
+ // pass on a table that renders no bar at all.
+ expect(before).not.toBeNull();
+ table.rows = CATALOG;
+ await table.updateComplete;
+ expect(table.shadowRoot!.querySelector('dex-filter-bar')).toBe(before);
+ table.remove();
+ });
});
describe('feedback when a search matches nothing', () => {
@@ -400,6 +463,28 @@ describe('feedback when a search matches nothing', () => {
table.remove();
});
+ it('names the conditions in the empty state the way the chips do', async () => {
+ const table = await mount(CATALOG);
+ table.columnLabels = { Name: 'Name', Value: 'Value', DataType: 'Data Type' };
+ await table.updateComplete;
+ await search(table, 'Name=nosuchthing');
+ const msg = table.shadowRoot!.querySelector('.no-match-state')!.textContent!.replace(/\s+/g, ' ').trim();
+ expect(msg).toBe('No entries match Name equal to “nosuchthing”');
+ table.remove();
+ });
+
+ it('joins several conditions into one sentence', async () => {
+ // The bar shows three chips; the message has to account for all three, or the
+ // user reads it as "one of my conditions was ignored".
+ const table = await mount(CATALOG);
+ table.columnLabels = { Name: 'Name', Value: 'Value', DataType: 'Data Type' };
+ await table.updateComplete;
+ await search(table, 'zzz Name:gain Value>10');
+ const msg = table.shadowRoot!.querySelector('.no-match-state')!.textContent!.replace(/\s+/g, ' ').trim();
+ expect(msg).toBe('No entries match “zzz”, Name containing “gain” and Value greater than “10”');
+ table.remove();
+ });
+
it('the message is absent whenever rows are showing', async () => {
const table = await mount(CATALOG);
expect(table.shadowRoot!.querySelector('.no-match-state')).toBeNull();
@@ -688,11 +773,38 @@ describe('a filtered list holds still while its rows are edited', () => {
const table = await mount(CATALOG);
await search(table, 'gain');
await repaint(table, edited('p1', { Name: { label: 'plainValue' } }));
- const input = table.shadowRoot!.querySelector('.filter-input') as HTMLInputElement;
- input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ boxOf(table).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
+ await barOf(table).updateComplete;
await table.updateComplete;
expect((table as any)._getVisibleRows().length).toBe(4);
expect(await search(table, 'gain')).toEqual([]);
table.remove();
});
});
+
+describe('columns are addressable by the label on their header', () => {
+ it('a multi-word label filters when quoted', async () => {
+ const table = await mount(CATALOG);
+ table.columnLabels = { Name: 'Name', Value: 'Value', DataType: 'Data Type', Status: 'Status' };
+ await table.updateComplete;
+ expect(await search(table, '"Data Type"=single')).toEqual(['p2']);
+ table.remove();
+ });
+
+ it('a label beats the legacy alias, so a project table resolves its own Type', async () => {
+ const table = new DexTreeTable();
+ table.columns = ['Name', 'Type', 'Location'];
+ table.columnLabels = { Name: 'Name', Type: 'Type', Location: 'Location' };
+ document.body.appendChild(table);
+ (table as any)._hiddenColumns = new Set();
+ table.rows = [
+ makeRow('a', null, 'ctrl', { Type: 'Model' } as any),
+ makeRow('b', null, 'util', { Type: 'Folder' } as any),
+ ];
+ (table as any)._visibleRowsCache = null;
+ table.requestUpdate();
+ await table.updateComplete;
+ expect(await search(table, 'Type=Model')).toEqual(['a']);
+ table.remove();
+ });
+});
diff --git a/test/treeTableLoading.test.ts b/test/treeTableLoading.test.ts
index 1dd0754..619a405 100644
--- a/test/treeTableLoading.test.ts
+++ b/test/treeTableLoading.test.ts
@@ -43,6 +43,10 @@ async function mount(opts: { loading?: boolean; rows?: TreeTableRow[] } = {}): P
const q = (table: DexTreeTable, sel: string) => table.shadowRoot!.querySelector(sel);
+// The one real search input, one shadow root down inside the chip bar.
+const box = (table: DexTreeTable): HTMLInputElement | null =>
+ (q(table, 'dex-filter-bar') as HTMLElement | null)?.shadowRoot?.querySelector('.filter-input') ?? null;
+
// The component's own stylesheet text, whitespace collapsed so a rule reads as one
// line — the only place a keyframe or an animation delay can be pinned.
const styleText = (): string => {
@@ -72,7 +76,7 @@ describe('waiting for the first payload', () => {
// the bar; the bar appeared at boot, vanished, and came back with the rows.
const table = await mount({ loading: true });
expect(q(table, '.filter-bar')).not.toBeNull();
- expect(q(table, '.filter-input')).not.toBeNull();
+ expect(box(table)).not.toBeNull();
table.remove();
});
@@ -106,12 +110,19 @@ describe('waiting for the first payload', () => {
// node: a second literal would be a different template, the input would be
// rebuilt, and text typed while waiting (plus the caret) would be lost.
const table = await mount({ loading: true });
- const before = q(table, '.filter-input') as HTMLInputElement;
+ const bar = q(table, 'dex-filter-bar') as HTMLElement & { updateComplete: Promise };
+ const before = box(table)!;
before.value = 'gain';
+ // Through a real input event, so the bar holds it as its pending tail rather than
+ // the test relying on a raw DOM value the next render would overwrite.
+ before.dispatchEvent(new Event('input', { bubbles: true }));
+ await bar.updateComplete;
table.loading = false;
table.rows = [makeRow('a')];
await table.updateComplete;
- const after = q(table, '.filter-input') as HTMLInputElement;
+ await bar.updateComplete;
+ expect(q(table, 'dex-filter-bar')).toBe(bar);
+ const after = box(table)!;
expect(after).toBe(before);
expect(after.value).toBe('gain');
table.remove();
|