From 6d60f9ad73cbd4e97d986a28318bb76e60df61de Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:06:58 -0400 Subject: [PATCH 01/16] [wip] add FilterToken and column-vocabulary types to the row filter --- src/webview/rowFilter.ts | 62 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/webview/rowFilter.ts b/src/webview/rowFilter.ts index f9b4b42..18401e4 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,33 @@ 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; +} + // 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 From f274c53a03bb3c0755574710ed642b95afa610c5 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:08:10 -0400 Subject: [PATCH 02/16] [wip] parse filter tokens with header-label prefixes and operators --- src/webview/rowFilter.ts | 200 +++++++++++++++++++++++++-------------- test/rowFilter.test.ts | 48 ++++++++++ 2 files changed, 178 insertions(+), 70 deletions(-) diff --git a/src/webview/rowFilter.ts b/src/webview/rowFilter.ts index 18401e4..0286787 100644 --- a/src/webview/rowFilter.ts +++ b/src/webview/rowFilter.ts @@ -120,6 +120,75 @@ function resolveColumn(prefix: string, labelMap: Map, vocab?: Co 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 @@ -132,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) || []; + const labelMap = buildLabelMap(vocabulary); - // 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. + // 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); + 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; - 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)); - } + // 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( diff --git a/test/rowFilter.test.ts b/test/rowFilter.test.ts index 6ecf15e..5d3bb42 100644 --- a/test/rowFilter.test.ts +++ b/test/rowFilter.test.ts @@ -230,3 +230,51 @@ 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']); + }); +}); From 952c8895e1242a429330f74ad8b739e0ff63d117 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:08:29 -0400 Subject: [PATCH 03/16] [wip] define = as numeric-when-numeric, case-insensitive otherwise --- test/rowFilter.test.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/rowFilter.test.ts b/test/rowFilter.test.ts index 5d3bb42..034b482 100644 --- a/test/rowFilter.test.ts +++ b/test/rowFilter.test.ts @@ -278,3 +278,37 @@ describe('the operator scanner', () => { 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'); + }); +}); From 099413a4f570c066ddbf358c07765216332c2c65 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:09:03 -0400 Subject: [PATCH 04/16] [wip] add formatToken and removeToken, the one speller and splicer --- src/webview/rowFilter.ts | 28 ++++++++++++++++++++++++++++ test/rowFilter.test.ts | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/webview/rowFilter.ts b/src/webview/rowFilter.ts index 0286787..7b6a059 100644 --- a/src/webview/rowFilter.ts +++ b/src/webview/rowFilter.ts @@ -351,3 +351,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/rowFilter.test.ts b/test/rowFilter.test.ts index 034b482..bfebd96 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; @@ -312,3 +314,33 @@ describe('the = rule', () => { 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"'); + }); +}); From 4915247a1a208fe53226656942f97bf6fb0f647a Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:09:19 -0400 Subject: [PATCH 05/16] [wip] pin quoting as grouping only, with = as the exact operator --- test/rowFilter.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/test/rowFilter.test.ts b/test/rowFilter.test.ts index bfebd96..da5a702 100644 --- a/test/rowFilter.test.ts +++ b/test/rowFilter.test.ts @@ -111,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', () => { From 79ddc9e342a7eedd3e2b2be63be8f6cd431fa63b Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:11:16 -0400 Subject: [PATCH 06/16] [wip] resolve filter prefixes from the table's own column labels --- src/webview/components/dex-tree-table.ts | 49 +++++++++++++++++------- src/webview/rowFilter.ts | 3 +- test/treeTableFilter.test.ts | 38 ++++++++++++++++-- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/src/webview/components/dex-tree-table.ts b/src/webview/components/dex-tree-table.ts index 5fc5808..5f916c2 100644 --- a/src/webview/components/dex-tree-table.ts +++ b/src/webview/components/dex-tree-table.ts @@ -4,7 +4,9 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, property, state, query } from 'lit/decorators.js'; import { highContrastStyles } from './styles/high-contrast.styles.js'; import { dragModeFromModifiers, type DragMode } from './dragMode.js'; -import { filterRows, parseFilterExpression, type FilterTerm } from '../rowFilter.js'; +import { + filterRows, parseFilterExpression, removeToken, type FilterTerm, type FilterToken, +} from '../rowFilter.js'; import './dex-icon.js'; import './dex-matrix-open.js'; import type { MatrixPayload } from './dex-matrix-grid.js'; @@ -1141,7 +1143,7 @@ export class DexTreeTable extends LitElement { @state() private _expandedIds: Set = new Set(); @state() private _filterText = ''; // Not @state: derived from _filterText, and keyed by it so it cannot go stale. - private _filterTermsCache: { text: string; terms: FilterTerm[] } | null = null; + private _filterParseCache: { text: string; terms: FilterTerm[]; tokens: FilterToken[] } | null = null; // Rows kept in the filtered list although they no longer match, because the user // edited them there. Written by installRows (table-main.ts) on every repaint from // nextStickyIds; dropped here the moment the user searches again. @@ -1938,31 +1940,50 @@ export class DexTreeTable extends LitElement { // MEANS belongs here, emitted to both consumers together, rather than being // guessed a second time at render. private _parseFilterExpression(text: string): { + tokens: FilterToken[]; predicates: Array<(row: TreeTableRow) => boolean>; terms: FilterTerm[]; } { // An unqualified term matches against every visible column, so the search - // stays in sync with whatever columns the user actually sees (Class, Kind, - // etc.) instead of a hardcoded subset. `_getCellText` is passed rather than - // reached for inside rowFilter.ts because reading a cell's text depends on - // cell shape, which is this component's concern, not the grammar's. - return parseFilterExpression(text, this._visibleColumns, (row, col) => this._getCellText(row, col)); + // stays in sync with whatever columns the user actually sees. The VOCABULARY, + // by contrast, is every column the table has — a `Unit=` typed by hand has to + // work with Unit hidden, and only a bare term is limited to what is on screen. + // `_getCellText` is passed rather than reached for inside rowFilter.ts because + // reading a cell's text depends on cell shape, which is this component's + // concern, not the grammar's. + return parseFilterExpression(text, this._visibleColumns, (row, col) => this._getCellText(row, col), { + labels: this.columnLabels, + keys: this.columns, + }); } - // The terms behind the current search, cached because _highlight runs once per - // rendered cell and the tokens only change when the box does. - private get _filterTerms(): FilterTerm[] { - if (this._filterTermsCache?.text !== this._filterText) { - this._filterTermsCache = { text: this._filterText, terms: this._parseFilterExpression(this._filterText).terms }; + // The parse behind the current search, cached because _highlight runs once per + // rendered cell and the chips render once per repaint, while the tokens only + // change when the box does. + private get _filterParse(): { terms: FilterTerm[]; tokens: FilterToken[] } { + if (this._filterParseCache?.text !== this._filterText) { + const { terms, tokens } = this._parseFilterExpression(this._filterText); + this._filterParseCache = { text: this._filterText, terms, tokens }; } - return this._filterTermsCache.terms; + return this._filterParseCache; + } + + private get _filterTerms(): FilterTerm[] { + return this._filterParse.terms; + } + + private get _filterTokens(): FilterToken[] { + return this._filterParse.tokens; } private _filterRows(rows: TreeTableRow[], text: string): TreeTableRow[] { // `_stickyRowIds` is passed rather than reached for inside rowFilter.ts // because it is this component's edit-tracking state, not part of the // grammar (see nextStickyIds in rowUpdates.ts). - return filterRows(rows, text, this._visibleColumns, (row, col) => this._getCellText(row, col), this._stickyRowIds); + return filterRows( + rows, text, this._visibleColumns, (row, col) => this._getCellText(row, col), this._stickyRowIds, + { labels: this.columnLabels, keys: this.columns }, + ); } private _flattenToVisible(rows: TreeTableRow[]): TreeTableRow[] { diff --git a/src/webview/rowFilter.ts b/src/webview/rowFilter.ts index 7b6a059..fdd49c6 100644 --- a/src/webview/rowFilter.ts +++ b/src/webview/rowFilter.ts @@ -286,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(); diff --git a/test/treeTableFilter.test.ts b/test/treeTableFilter.test.ts index 71a37b6..f869e76 100644 --- a/test/treeTableFilter.test.ts +++ b/test/treeTableFilter.test.ts @@ -234,14 +234,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(); }); @@ -696,3 +701,30 @@ describe('a filtered list holds still while its rows are edited', () => { 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(); + }); +}); From 4a831c2e4e0d19d8ba713c74c6f5963af3921377 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:19:28 -0400 Subject: [PATCH 07/16] [wip] add the column filter popup, whose writes line is what Apply writes --- src/webview/components/dex-column-filter.ts | 264 ++++++++++++++++++++ test/columnFilterPopup.test.ts | 101 ++++++++ 2 files changed, 365 insertions(+) create mode 100644 src/webview/components/dex-column-filter.ts create mode 100644 test/columnFilterPopup.test.ts diff --git a/src/webview/components/dex-column-filter.ts b/src/webview/components/dex-column-filter.ts new file mode 100644 index 0000000..ff567fc --- /dev/null +++ b/src/webview/components/dex-column-filter.ts @@ -0,0 +1,264 @@ +// Copyright 2026 The MathWorks, Inc. +// +// The filter popup a right-click on a column header opens. It does NOT filter +// anything: it composes one condition's TEXT and hands it to the table, which puts +// it in the search box and re-parses. That is deliberate — a popup that filtered +// on its own would be a second path deciding what a condition means, and the two +// paths would drift. The `writes:` line is the same string `formatToken` gives +// Apply, so the teaching line cannot lie. + +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { formatToken, type FilterOp } from '../rowFilter.js'; + +// Glyph per operator: chips and radios must agree, and `≠ ≥ ≤` read faster than +// their ASCII spellings. `contains` shows the colon it writes. +export const OP_GLYPHS: ReadonlyArray = [ + ['contains', ':', 'contains'], + ['=', '=', 'equals'], + ['!=', '≠', 'does not equal'], + ['>', '>', 'greater than'], + ['<', '<', 'less than'], + ['>=', '≥', 'greater than or equal to'], + ['<=', '≤', 'less than or equal to'], +]; + +@customElement('dex-column-filter') +export class DexColumnFilter extends LitElement { + static override styles = css` + :host { + position: fixed; + z-index: 1001; + display: block; + font-family: var(--dex-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); + font-size: 12px; + color: var(--dex-color-text, inherit); + background: var(--dex-bg-primary, #fff); + border: 1px solid var(--dex-border-color, #d0d0d0); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + padding: 8px; + min-width: 240px; + } + .popup-title { + font-weight: 600; + margin-bottom: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .op-row { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 6px; + } + .op-button { + min-width: 26px; + height: 22px; + padding: 0 6px; + border: 1px solid var(--dex-border-color, #d0d0d0); + border-radius: 3px; + background: var(--dex-bg-primary, #fff); + color: inherit; + font: inherit; + cursor: pointer; + outline: none; + } + .op-button:hover { + background: var(--dex-bg-hover, #e8e8e8); + } + .op-button[aria-pressed='true'] { + border-color: var(--dex-color-accent, #0078d4); + background: var(--dex-bg-selected, #cce4f7); + } + .op-button:focus-visible { + border-color: var(--dex-color-accent, #0078d4); + } + .popup-value { + width: 100%; + height: 24px; + padding: 2px 6px; + box-sizing: border-box; + border: 1px solid var(--dex-border-color, #d0d0d0); + border-radius: 3px; + font: inherit; + outline: none; + } + .popup-value:focus { + border-color: var(--dex-color-accent, #0078d4); + } + /* The teaching line. Monospace because it is literal syntax to retype. */ + .writes { + display: flex; + gap: 6px; + margin: 6px 0; + min-height: 16px; + font-size: 11px; + color: var(--dex-color-text-secondary, #666); + } + .writes-value { + font-family: var(--dex-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + overflow-wrap: anywhere; + } + .popup-actions { + display: flex; + justify-content: flex-end; + gap: 6px; + } + .popup-actions button { + height: 22px; + padding: 0 10px; + border: 1px solid var(--dex-border-color, #d0d0d0); + border-radius: 3px; + background: var(--dex-bg-primary, #fff); + color: inherit; + font: inherit; + cursor: pointer; + } + .popup-actions button:hover { + background: var(--dex-bg-hover, #e8e8e8); + } + .popup-apply { + border-color: var(--dex-color-accent, #0078d4) !important; + } + /* Forced colors override every background and border above, so the chosen + operator would look identical to the six others — the one piece of state in + here that a user has to be able to see. A system-colour outline survives. */ + @media (forced-colors: active) { + .op-button, + .popup-value, + .popup-actions button { + border: 1px solid ButtonText !important; + } + .op-button[aria-pressed='true'] { + outline: 2px solid Highlight !important; + outline-offset: -3px !important; + } + .op-button:focus-visible, + .popup-value:focus-visible, + .popup-actions button:focus-visible { + outline: 2px solid Highlight !important; + outline-offset: 1px !important; + } + } + `; + + /** Column key the condition is about. */ + @property({ type: String }) column = ''; + /** Header label; also the prefix the written text uses. */ + @property({ type: String }) columnLabel = ''; + @property({ type: String }) op: FilterOp = 'contains'; + @property({ type: String }) value = ''; + /** True when the table already has a condition for this column. */ + @property({ type: Boolean }) hasExisting = false; + + @query('.popup-value') private _valueInput?: HTMLInputElement; + + /** Focus the value box; the caller opens the popup for typing, not for reading. */ + focusValue(): void { + this._valueInput?.focus(); + this._valueInput?.select(); + } + + override firstUpdated(): void { + this.focusValue(); + } + + private get _text(): string { + // `contains` with nothing typed yet would preview `"Data Type":`, which promises + // a condition the user has not written. Every other operator DOES write on an + // empty value — `Unit=` asks which entries have no Unit, a real question. + if (!this.value && this.op === 'contains') return ''; + return formatToken(this.columnLabel || this.column, this.op, this.value); + } + + private _apply(): void { + this.dispatchEvent( + new CustomEvent('dex-column-filter-applied', { + detail: { column: this.column, op: this.op, value: this.value, text: this._text }, + bubbles: true, + composed: true, + }), + ); + } + + private _close(): void { + this.dispatchEvent(new CustomEvent('dex-column-filter-closed', { bubbles: true, composed: true })); + } + + private _clear(): void { + this.dispatchEvent( + new CustomEvent('dex-column-filter-cleared', { + detail: { column: this.column }, + bubbles: true, + composed: true, + }), + ); + } + + private _onKeyDown(e: KeyboardEvent): void { + if (e.key === 'Enter') { + e.preventDefault(); + this._apply(); + } else if (e.key === 'Escape') { + // Stopped so the table's own Escape (which clears the whole filter) does not + // also fire — closing a popup must not throw away the user's search. + e.preventDefault(); + e.stopPropagation(); + this._close(); + } + } + + override render() { + return html` + +
+ ${OP_GLYPHS.map( + ([op, glyph, label]) => html` + + `, + )} +
+ { + this.value = (e.target as HTMLInputElement).value; + }} + @keydown=${this._onKeyDown} + /> +
+ writes:${this._text} +
+ + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'dex-column-filter': DexColumnFilter; + } +} diff --git a/test/columnFilterPopup.test.ts b/test/columnFilterPopup.test.ts new file mode 100644 index 0000000..0777919 --- /dev/null +++ b/test/columnFilterPopup.test.ts @@ -0,0 +1,101 @@ +// 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'; + +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"='); + }); +}); From 08de53876e9976e2ba9e896538a0fc265d1e7d33 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:21:27 -0400 Subject: [PATCH 08/16] [wip] open the filter popup from a header right-click or its funnel --- src/webview/components/dex-tree-table.ts | 159 ++++++++++++++++++++++- 1 file changed, 155 insertions(+), 4 deletions(-) diff --git a/src/webview/components/dex-tree-table.ts b/src/webview/components/dex-tree-table.ts index 5f916c2..71b38dc 100644 --- a/src/webview/components/dex-tree-table.ts +++ b/src/webview/components/dex-tree-table.ts @@ -5,8 +5,10 @@ import { customElement, property, state, query } from 'lit/decorators.js'; import { highContrastStyles } from './styles/high-contrast.styles.js'; import { dragModeFromModifiers, type DragMode } from './dragMode.js'; import { - filterRows, parseFilterExpression, removeToken, type FilterTerm, type FilterToken, + filterRows, formatToken, parseFilterExpression, removeToken, + type FilterOp, type FilterTerm, type FilterToken, } from '../rowFilter.js'; +import './dex-column-filter.js'; import './dex-icon.js'; import './dex-matrix-open.js'; import type { MatrixPayload } from './dex-matrix-grid.js'; @@ -503,6 +505,46 @@ export class DexTreeTable extends LitElement { box-shadow: inset -3px 0 0 0 var(--dex-color-accent, #0078d4); } + /* Hidden until the header is hovered or the button itself is focused, so + eighteen funnels do not compete with eighteen labels; always visible once + that column is actually filtered, because then it is state, not an affordance. */ + .th-filter { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 2px; + background: none; + color: inherit; + cursor: pointer; + opacity: 0; + outline: none; + } + + th:hover .th-filter, + .th-filter:focus-visible, + .th-filter.active { + opacity: 0.75; + } + + .th-filter:hover { + opacity: 1; + background: var(--dex-bg-hover, #e8e8e8); + } + + .th-filter.active { + color: var(--dex-color-accent, #0078d4); + opacity: 1; + } + + .th-filter:focus-visible { + outline: 1px solid var(--dex-color-accent, #0078d4); + } + .column-menu { position: fixed; z-index: 1000; @@ -1211,6 +1253,12 @@ export class DexTreeTable extends LitElement { @state() private _menuDragOverCol: string | null = null; @state() private _menuDragOverSide: 'top' | 'bottom' | null = null; + // The column whose filter popup is open, null when none is. Anchored in + // viewport coordinates because the popup is position:fixed, like the column menu. + @state() private _filterPopupCol: string | null = null; + @state() private _filterPopupX = 0; + @state() private _filterPopupY = 0; + private _resizingCol: string | null = null; private _resizeStartX = 0; private _resizeStartWidth = 0; @@ -1274,16 +1322,25 @@ export class DexTreeTable extends LitElement { if (this._columnMenuOpen) { this._columnMenuOpen = false; } + this._filterPopupCol = null; } private _onDocumentClick(e: Event): void { + const path = e.composedPath(); if (this._columnMenuOpen) { - const path = e.composedPath(); const menu = this.shadowRoot?.querySelector('.column-menu'); if (menu && !path.includes(menu)) { this._columnMenuOpen = false; } } + if (this._filterPopupCol) { + // composedPath, not `contains`: the popup's controls live in its own shadow + // root, so a click on Apply is not a descendant of the host in the light DOM. + const popup = this.shadowRoot?.querySelector('dex-column-filter'); + if (popup && !path.includes(popup)) { + this._filterPopupCol = null; + } + } } private _loadPersistedState(): void { @@ -1598,6 +1655,44 @@ export class DexTreeTable extends LitElement { this._columnMenuOpen = true; } + // --- Per-column filter popup --- + + // Right-click, or the funnel button, on a column header. Prefills from the + // token the applied text already holds for this column, so opening a filtered + // column twice edits its condition instead of stacking a second one. + private _openColumnFilter(col: string, anchor: HTMLElement): void { + const rect = anchor.getBoundingClientRect(); + this._filterPopupX = Math.min(rect.left, Math.max(0, window.innerWidth - 260)); + this._filterPopupY = rect.bottom + 2; + this._filterPopupCol = col; + this._columnMenuOpen = false; + } + + private _onHeaderContextMenu(col: string, e: MouseEvent): void { + // preventDefault suppresses the native menu; stopPropagation keeps the + // container's own contextmenu handler out of it. + e.preventDefault(); + e.stopPropagation(); + this._openColumnFilter(col, e.currentTarget as HTMLElement); + } + + private _onFunnelClick(col: string, e: MouseEvent): void { + // Without stopPropagation the click reaches the and SORTS the column — + // opening a filter must not reorder the table under the user. + e.preventDefault(); + e.stopPropagation(); + if (this._filterPopupCol === col) { + this._filterPopupCol = null; + return; + } + this._openColumnFilter(col, (e.currentTarget as HTMLElement).closest('th') as HTMLElement); + } + + /** The token in the applied text scoped to `col`, if there is one. */ + private _tokenForColumn(col: string): FilterToken | undefined { + return this._filterTokens.find((t) => t.column === col); + } + private _toggleColumnVisibility(col: string): void { if (col === 'Name') return; const updated = new Set(this._hiddenColumns); @@ -1729,6 +1824,41 @@ export class DexTreeTable extends LitElement { return html`${entry.direction === 'asc' ? '▲' : '▼'}`; } + // The standing cue that a column is filterable: faint on hover, solid while that + // column has a condition. A real + `; + } + // Sort a group of sibling rows in place. Sorting must never cross // parent/child boundaries, so this is applied per-level in // _flattenToVisible rather than to the flattened tree — otherwise a child @@ -3090,7 +3220,8 @@ export class DexTreeTable extends LitElement { const body = this.rows.length === 0 ? this._renderNoRows() : this._renderTable(allVisible, totalRows, visibleCols); return html` - ${this._renderFilterBar()} ${body} ${this._renderColumnMenu()} ${this._renderDropTooltip()} + ${this._renderFilterBar()} ${body} ${this._renderColumnMenu()} ${this._renderColumnFilterPopup()} + ${this._renderDropTooltip()} `; } @@ -3152,6 +3283,7 @@ export class DexTreeTable extends LitElement { ? 'drag-over-left' : ''} ${this._dragOverColId === col && this._dragOverSide === 'right' ? 'drag-over-right' : ''}" @click=${(e: MouseEvent) => this._onHeaderClick(col, e)} + @contextmenu=${(e: MouseEvent) => this._onHeaderContextMenu(col, e)} @dragstart=${(e: DragEvent) => this._onHeaderDragStart(col, e)} @dragover=${(e: DragEvent) => this._onHeaderDragOver(col, e)} @dragleave=${(e: DragEvent) => this._onHeaderDragLeave(e)} @@ -3160,7 +3292,7 @@ export class DexTreeTable extends LitElement { >
${this.columnLabels?.[col] || col} - ${this._getSortIndicator(col)} + ${this._renderFunnel(col)} ${this._getSortIndicator(col)}
{ + this._filterPopupCol = null; + }} + > + `; + } + private _renderColumnMenu() { if (!this._columnMenuOpen) return nothing; return html` From f7b68a83d542bbd7ca978eda1bda719d1839a84b Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:24:07 -0400 Subject: [PATCH 09/16] [wip] apply, replace and clear one column's condition from the popup --- src/webview/components/dex-tree-table.ts | 52 ++++++++++- test/columnFilterPopup.test.ts | 108 +++++++++++++++++++++++ 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/src/webview/components/dex-tree-table.ts b/src/webview/components/dex-tree-table.ts index 71b38dc..95fc548 100644 --- a/src/webview/components/dex-tree-table.ts +++ b/src/webview/components/dex-tree-table.ts @@ -1693,6 +1693,46 @@ export class DexTreeTable extends LitElement { return this._filterTokens.find((t) => t.column === col); } + // Splice the condition for `col` into the applied text: in place if the column + // already has one, appended otherwise. Appending unconditionally would build + // `Name:a Name:b` — two conditions ANDed, so nothing matches, which reads as a + // bug rather than as a replaced filter. + private _applyColumnFilter(col: string, op: FilterOp, value: string): void { + const label = this.columnLabels?.[col] || col; + const text = formatToken(label, op, value); + const existing = this._tokenForColumn(col); + const next = existing + ? `${this._filterText.slice(0, existing.start)}${text}${this._filterText.slice(existing.end)}` + : this._filterText + ? `${this._filterText} ${text}` + : text; + this._setFilterText(next); + } + + private _clearColumnFilter(col: string): void { + const existing = this._tokenForColumn(col); + if (existing) this._setFilterText(removeToken(this._filterText, existing)); + this._filterPopupCol = null; + } + + // The ONE place the applied text changes. Everything that filters — Enter, popup + // Apply, Escape — goes through here, so "a new search resets the sticky rows" is + // stated once instead of at four call sites. + private _setFilterText(text: string): void { + this._filterText = text; + this._newSearch(); + } + + private _onColumnFilterApplied(e: CustomEvent): void { + const { column, op, value } = e.detail as { column: string; op: FilterOp; value: string }; + this._applyColumnFilter(column, op, value); + this._filterPopupCol = null; + } + + private _onColumnFilterCleared(e: CustomEvent): void { + this._clearColumnFilter((e.detail as { column: string }).column); + } + private _toggleColumnVisibility(col: string): void { if (col === 'Name') return; const updated = new Set(this._hiddenColumns); @@ -2732,15 +2772,16 @@ export class DexTreeTable extends LitElement { } private _onFilterInput(e: Event): void { - this._filterText = (e.target as HTMLInputElement).value.trim(); - this._newSearch(); + this._setFilterText((e.target as HTMLInputElement).value.trim()); } private _onFilterKeyDown(e: KeyboardEvent): void { if (e.key === 'Escape') { - this._filterText = ''; + // The box is cleared directly as well as through the binding: Escape on an + // already-empty filter leaves _filterText unchanged, and then the binding has + // nothing to commit and whitespace the user typed would stay on screen. this._filterInput.value = ''; - this._newSearch(); + this._setFilterText(''); } } @@ -3197,6 +3238,7 @@ export class DexTreeTable extends LitElement { type="search" class="filter-input" placeholder="Search" + .value=${this._filterText} @input=${this._onFilterInput} @keydown=${this._onFilterKeyDown} /> @@ -3426,6 +3468,8 @@ export class DexTreeTable extends LitElement { .op=${existing?.op ?? 'contains'} .value=${existing?.value ?? ''} .hasExisting=${existing !== undefined} + @dex-column-filter-applied=${this._onColumnFilterApplied} + @dex-column-filter-cleared=${this._onColumnFilterCleared} @dex-column-filter-closed=${() => { this._filterPopupCol = null; }} diff --git a/test/columnFilterPopup.test.ts b/test/columnFilterPopup.test.ts index 0777919..d8ff704 100644 --- a/test/columnFilterPopup.test.ts +++ b/test/columnFilterPopup.test.ts @@ -9,6 +9,8 @@ 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; @@ -99,3 +101,109 @@ describe('dex-column-filter', () => { 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 box shows the text the popup wrote', async () => { + // Apply that filters without updating the box would leave the user looking at a + // table narrowed by text they cannot see, edit, or clear. + const el = await table(); + (el as any)._applyColumnFilter('DataType', 'contains', 'double'); + await el.updateComplete; + expect((el.shadowRoot!.querySelector('.filter-input') as HTMLInputElement).value).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([]); + }); +}); From 569c0755d4ffac438213743ab4f669f2dbc57c04 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:31:24 -0400 Subject: [PATCH 10/16] [wip] add the chip filter bar: one chip per condition, one real input --- src/webview/components/dex-filter-bar.ts | 283 +++++++++++++++++++++++ test/filterBar.test.ts | 108 +++++++++ 2 files changed, 391 insertions(+) create mode 100644 src/webview/components/dex-filter-bar.ts create mode 100644 test/filterBar.test.ts diff --git a/src/webview/components/dex-filter-bar.ts b/src/webview/components/dex-filter-bar.ts new file mode 100644 index 0000000..c8393de --- /dev/null +++ b/src/webview/components/dex-filter-bar.ts @@ -0,0 +1,283 @@ +// Copyright 2026 The MathWorks, Inc. +// +// The search bar as a token field: one chip per condition, one for the tail +// the user is still typing. Exactly ONE real input — chips are siblings, not +// contenteditable — so the caret, selection, IME and undo stay the browser's job. +// A contenteditable token field owns all of that itself and gets it subtly wrong. +// +// The bar does not filter and does not own the filter. It receives the applied text +// and its parse, and proposes a REPLACEMENT for the whole text in one event. The +// only state it keeps for itself is the uncommitted tail, which is the one thing the +// table has no use for. + +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, state, query } from 'lit/decorators.js'; +import { removeToken, type FilterToken, type FilterOp } from '../rowFilter.js'; +import { OP_GLYPHS } from './dex-column-filter.js'; + +const GLYPH = new Map(OP_GLYPHS.map(([op, glyph]) => [op, glyph])); +const OP_WORD = new Map(OP_GLYPHS.map(([op, , label]) => [op, label])); + +const WARNING_TEXT: Record, string> = { + 'unknown-column': 'No column by that name — searched as ordinary text.', + 'non-numeric-bound': 'The bound is not a number, so this condition is ignored.', +}; + +@customElement('dex-filter-bar') +export class DexFilterBar extends LitElement { + static override styles = css` + :host { + display: flex; + flex: 1 1 auto; + min-width: 0; + align-items: center; + gap: 4px; + /* At most two rows tall, then scroll: an unbounded bar pushes the table down + as conditions accumulate, and the row under the caret is the one that matters. */ + max-height: 52px; + overflow-y: auto; + padding: 2px 6px; + box-sizing: border-box; + border: 1px solid var(--dex-border-color, #d0d0d0); + border-radius: 3px; + background: var(--dex-bg-primary, #fff); + font-family: inherit; + font-size: 12px; + flex-wrap: wrap; + } + :host(.focused) { + border-color: var(--dex-color-accent, #0078d4); + } + .chip-strip { + display: contents; + } + .chip { + display: inline-flex; + align-items: baseline; + gap: 3px; + max-width: 100%; + padding: 1px 2px 1px 6px; + border-radius: 9px; + background: var(--dex-bg-badge, rgba(128, 128, 128, 0.18)); + white-space: nowrap; + } + /* A column-scoped chip is tinted with the accent so the eye can separate "this + condition names a column" from "this is a word to look for anywhere" without + reading either. Alpha over the theme accent, never a fixed hue — a literal + pill colour is unreadable in dark and invisible in high contrast. */ + .chip.column { + background: color-mix(in srgb, var(--dex-color-accent, #0078d4) 18%, transparent); + } + .chip.warning { + background: color-mix(in srgb, var(--dex-color-warning, #bf8803) 22%, transparent); + } + /* Quiet and small: the column is context for the value, not the point of it. */ + .chip-label { + font-size: 11px; + color: var(--dex-color-text-secondary, #666); + overflow: hidden; + text-overflow: ellipsis; + } + /* Muted like the label, but full size and semibold. "Unit: m" versus "Unit ≠ m" + is the entire meaning of the chip, and 11px grey is not where that belongs. */ + .chip-op { + font-size: 12px; + font-weight: 600; + color: var(--dex-color-text-secondary, #666); + } + .chip-op.subtle { + font-weight: 400; + } + .chip-value { + overflow: hidden; + text-overflow: ellipsis; + } + .chip-remove { + flex: 0 0 auto; + width: 14px; + height: 14px; + padding: 0; + border: none; + border-radius: 7px; + background: none; + color: var(--dex-color-text-secondary, #666); + font: inherit; + line-height: 1; + cursor: pointer; + outline: none; + } + .chip-remove:hover { + background: var(--dex-bg-hover, #e8e8e8); + color: var(--dex-color-text, inherit); + } + .chip-remove:focus-visible { + outline: 1px solid var(--dex-color-accent, #0078d4); + } + .filter-input { + flex: 1 1 60px; + min-width: 60px; + height: 20px; + padding: 0 2px; + border: none; + background: none; + color: inherit; + font: inherit; + outline: none; + } + /* Without this, Enter-to-filter reads as a search box that stopped working. */ + .pending-hint { + flex: 0 0 auto; + padding-right: 2px; + font-size: 11px; + color: var(--dex-color-text-secondary, #666); + white-space: nowrap; + } + /* Forced colors drops every background above, so the three chip kinds — bare, + column-scoped, warning — would be one undifferentiated shape. A border in a + system colour survives, and the warning one takes the accent so "this + condition is not doing what it says" is still visible without colour. */ + @media (forced-colors: active) { + :host { + border: 1px solid ButtonText !important; + } + .chip { + border: 1px solid ButtonText !important; + } + .chip.warning { + border: 2px solid Highlight !important; + } + .chip-remove:focus-visible { + outline: 2px solid Highlight !important; + } + } + `; + + /** The applied filter text. The bar never mutates it; it proposes replacements. */ + @property({ type: String }) text = ''; + /** Its parse, from the table's one `parseFilterExpression` call. */ + @property({ attribute: false }) tokens: FilterToken[] = []; + @property({ type: String }) placeholder = 'Search'; + + /** The uncommitted tail. The one piece of state that is the bar's alone. */ + @state() private _tail = ''; + + @query('.filter-input') private _input?: HTMLInputElement; + + /** Focus (and select) the tail — the Ctrl+F entry point, forwarded by the table. */ + focusInput(): void { + this._input?.focus(); + this._input?.select(); + } + + private _propose(text: string): void { + this.dispatchEvent( + new CustomEvent('dex-filter-applied', { detail: { text }, bubbles: true, composed: true }), + ); + } + + private _commitTail(): void { + const tail = this._tail.trim(); + if (!tail) return; + this._tail = ''; + this._propose(this.text ? `${this.text} ${tail}` : tail); + } + + private _removeAt(index: number): void { + const token = this.tokens[index]; + if (token) this._propose(removeToken(this.text, token)); + } + + private _onKeyDown(e: KeyboardEvent): void { + const el = e.target as HTMLInputElement; + if (e.key === 'Enter') { + e.preventDefault(); + this._commitTail(); + return; + } + if (e.key === 'Escape') { + // Two stages. A pending tail gets its own undo; a second press clears the + // filter, which is what Escape has always done here. Reversing the order would + // throw away an applied search on the way to abandoning a half-typed word. + e.preventDefault(); + e.stopPropagation(); + if (this._tail) { + this._tail = ''; + } else if (this.text) { + this._propose(''); + } + return; + } + if ( + e.key === 'Backspace' && + !this._tail && + el.selectionStart === 0 && + el.selectionEnd === 0 && + this.tokens.length > 0 + ) { + // Backspace into the chips edits the last one rather than deleting it blind: + // its RAW text — the user's own spelling, quotes and `~=` included — comes back + // into the input, so a typo is a correction instead of a retype. + e.preventDefault(); + const last = this.tokens[this.tokens.length - 1]; + this._tail = last.raw; + this._propose(removeToken(this.text, last)); + } + } + + private _renderChip(token: FilterToken, index: number) { + const kind = token.warning ? 'warning' : token.column ? 'column' : 'bare'; + const glyph = GLYPH.get(token.op) ?? ':'; + const word = OP_WORD.get(token.op) ?? 'contains'; + const label = token.columnLabel; + return html` + + ${label ? html`${label}` : nothing} + ${label ? html`${glyph}` : nothing} + ${token.value} + + + `; + } + + override render() { + return html` + + ${this.tokens.map((token, i) => this._renderChip(token, i))} + + { + this._tail = (e.target as HTMLInputElement).value; + }} + @keydown=${this._onKeyDown} + @focus=${() => this.classList.add('focused')} + @blur=${() => this.classList.remove('focused')} + /> + ${this._tail.trim() ? html`⏎ to filter` : nothing} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'dex-filter-bar': DexFilterBar; + } +} diff --git a/test/filterBar.test.ts b/test/filterBar.test.ts new file mode 100644 index 0000000..b4289b8 --- /dev/null +++ b/test/filterBar.test.ts @@ -0,0 +1,108 @@ +// 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']); + }); +}); From 14327eb3a2a4167e6ee9d16540c9aa896711939b Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:31:43 -0400 Subject: [PATCH 11/16] [wip] pin Enter-to-filter, chip Backspace, and two-stage Escape --- test/filterBar.test.ts | 79 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/test/filterBar.test.ts b/test/filterBar.test.ts index b4289b8..30eb014 100644 --- a/test/filterBar.test.ts +++ b/test/filterBar.test.ts @@ -106,3 +106,82 @@ describe('dex-filter-bar', () => { 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(['']); + }); +}); From ea922cae3b827bc7bc3bfd790ad43b43bde2e083 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:32:31 -0400 Subject: [PATCH 12/16] [wip] render the chip bar in the table and filter on Enter --- src/webview/components/dex-tree-table.ts | 62 ++++++++---------------- 1 file changed, 19 insertions(+), 43 deletions(-) diff --git a/src/webview/components/dex-tree-table.ts b/src/webview/components/dex-tree-table.ts index 95fc548..9b28c90 100644 --- a/src/webview/components/dex-tree-table.ts +++ b/src/webview/components/dex-tree-table.ts @@ -9,6 +9,8 @@ import { type FilterOp, type FilterTerm, type FilterToken, } from '../rowFilter.js'; import './dex-column-filter.js'; +import './dex-filter-bar.js'; +import type { DexFilterBar } from './dex-filter-bar.js'; import './dex-icon.js'; import './dex-matrix-open.js'; import type { MatrixPayload } from './dex-matrix-grid.js'; @@ -313,22 +315,11 @@ export class DexTreeTable extends LitElement { border-color: var(--dex-color-accent, #0078d4); } - .filter-input { + /* The bar draws its own border, background and focus ring (dex-filter-bar.ts); + all this side owns is how much of the row it takes. */ + dex-filter-bar { flex: 1 1 auto; min-width: 0; - width: 100%; - height: 24px; - padding: 2px 8px; - border: 1px solid var(--dex-border-color, #d0d0d0); - border-radius: 3px; - font-size: 12px; - font-family: inherit; - box-sizing: border-box; - outline: none; - } - - .filter-input:focus { - border-color: var(--dex-color-accent, #0078d4); } .table-container { @@ -1274,7 +1265,7 @@ export class DexTreeTable extends LitElement { private _dragOverColId: string | null = null; private _dragOverSide: 'left' | 'right' | null = null; - @query('.filter-input') private _filterInput!: HTMLInputElement; + @query('dex-filter-bar') private _filterBar?: DexFilterBar; @query('.table-container') private _container!: HTMLElement; private get _rowH(): number { @@ -1718,8 +1709,11 @@ export class DexTreeTable extends LitElement { // The ONE place the applied text changes. Everything that filters — Enter, popup // Apply, Escape — goes through here, so "a new search resets the sticky rows" is // stated once instead of at four call sites. + // Trimmed here rather than at each caller: the bar trims every tail it commits, but + // removing a chip splices a span out of the text and can leave an edge space behind, + // and a leading space would make the applied text differ from what the chips say. private _setFilterText(text: string): void { - this._filterText = text; + this._filterText = text.trim(); this._newSearch(); } @@ -2763,26 +2757,11 @@ export class DexTreeTable extends LitElement { } } - /** Focus (and select) the search/filter input — e.g. for a Ctrl+F shortcut. */ + /** Focus (and select) the search input — e.g. for a Ctrl+F shortcut. */ focusFilter(): void { - const input = this._filterInput; - if (!input) return; - input.focus(); - input.select(); - } - - private _onFilterInput(e: Event): void { - this._setFilterText((e.target as HTMLInputElement).value.trim()); - } - - private _onFilterKeyDown(e: KeyboardEvent): void { - if (e.key === 'Escape') { - // The box is cleared directly as well as through the binding: Escape on an - // already-empty filter leaves _filterText unchanged, and then the binding has - // nothing to commit and whitespace the user typed would stay on screen. - this._filterInput.value = ''; - this._setFilterText(''); - } + // Forwarded rather than reached into: the input lives in the bar's shadow root, + // and table-main.ts only knows about this method. + this._filterBar?.focusInput(); } // Touching the box is the user asking for a fresh answer, so the rows the previous @@ -3234,14 +3213,11 @@ export class DexTreeTable extends LitElement { private _renderFilterBar() { return html`
- + this._setFilterText((e.detail as { text: string }).text)} + > ${this._renderColumnsButton()}
`; From 453169921fa14f5587bc125be0d1d971e6940b17 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:35:24 -0400 Subject: [PATCH 13/16] [wip] drive the table filter tests through the chip bar --- test/columnFilterPopup.test.ts | 17 ++++-- test/treeTableCells.test.ts | 32 +++++------ test/treeTableFilter.test.ts | 98 +++++++++++++++++++++++++++------- test/treeTableLoading.test.ts | 17 ++++-- 4 files changed, 121 insertions(+), 43 deletions(-) diff --git a/test/columnFilterPopup.test.ts b/test/columnFilterPopup.test.ts index d8ff704..34edd20 100644 --- a/test/columnFilterPopup.test.ts +++ b/test/columnFilterPopup.test.ts @@ -151,13 +151,22 @@ describe('the table and the popup together', () => { expect(popupOf(el)).toBeNull(); }); - it('the search box shows the text the popup wrote', async () => { - // Apply that filters without updating the box would leave the user looking at a - // table narrowed by text they cannot see, edit, or clear. + 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; - expect((el.shadowRoot!.querySelector('.filter-input') as HTMLInputElement).value).toBe('"Data Type":double'); + 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 () => { 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 f869e76..7005567 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); } @@ -340,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(); @@ -370,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(); }); @@ -386,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', () => { @@ -693,8 +751,8 @@ 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([]); 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(); From e14c2354173ee3c2c3b11ec12ec802b710df3445 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:36:06 -0400 Subject: [PATCH 14/16] [wip] describe the empty state in the words the chips use --- src/webview/components/dex-tree-table.ts | 26 +++++++++++++++++++++++- test/treeTableFilter.test.ts | 22 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/webview/components/dex-tree-table.ts b/src/webview/components/dex-tree-table.ts index 9b28c90..d186ac8 100644 --- a/src/webview/components/dex-tree-table.ts +++ b/src/webview/components/dex-tree-table.ts @@ -265,6 +265,18 @@ function groupBlockLinks(links: unknown): BlockLinkGroup[] { return groups; } +// Words rather than glyphs: this is a sentence, and `Data Type ≠ double` inside one +// reads as a typo. +const OP_WORD_TEXT: Record = { + contains: 'containing', + '=': 'equal to', + '!=': 'not equal to', + '>': 'greater than', + '<': 'less than', + '>=': 'at least', + '<=': 'at most', +}; + @customElement('dex-tree-table') export class DexTreeTable extends LitElement { static override styles = [ @@ -3205,6 +3217,18 @@ export class DexTreeTable extends LitElement { private _pendingFlashId: string | null = null; + // Described from the TOKENS, not the raw text: the bar shows chips, so the empty + // state has to name the same conditions the same way or the two disagree about + // what was asked. + private _noMatchMessage(): string { + const parts = this._filterTokens.map((t) => + t.columnLabel ? `${t.columnLabel} ${OP_WORD_TEXT[t.op]} “${t.value}”` : `“${t.value}”`, + ); + if (parts.length === 0) return 'No entries match'; + if (parts.length === 1) return `No entries match ${parts[0]}`; + return `No entries match ${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`; + } + // The search bar. Rendered by BOTH branches below from this one function, so the // bar is the same element in the same place whether or not there are rows yet: // Lit reuses it across the repaint that brings the table in, which is what makes @@ -3325,7 +3349,7 @@ export class DexTreeTable extends LitElement { ${totalRows === 0 && this._filterText ? html`
- No entries match "${this._filterText}" + ${this._noMatchMessage()}
` : nothing} diff --git a/test/treeTableFilter.test.ts b/test/treeTableFilter.test.ts index 7005567..cc0d0b7 100644 --- a/test/treeTableFilter.test.ts +++ b/test/treeTableFilter.test.ts @@ -463,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(); From c7d6ee44a2d7a15d1c4073eeaa6de1e207ac250a Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:38:00 -0400 Subject: [PATCH 15/16] [wip] pin that the chip bar leaves the browser its own editing keys --- test/filterBar.test.ts | 43 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/filterBar.test.ts b/test/filterBar.test.ts index 30eb014..6a3e5b3 100644 --- a/test/filterBar.test.ts +++ b/test/filterBar.test.ts @@ -185,3 +185,46 @@ describe('dex-filter-bar keyboard', () => { 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([]); + }); +}); From a8a78ea47f2f6eb71442014c247a4fbd2a100fcf Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 13:52:13 -0400 Subject: [PATCH 16/16] Document the column filter syntax and the filter chips --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 461272e..8dc7786 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ It adds a native experience for Simulink file types — a **Simulink Data Explor - **Live two-way sync (textual `.sldd`)** — because a textual (JSON) `.sldd` is backed by its JSON text document, edits in the table and edits in the JSON text editor update each other instantly, and there is a single shared undo history across both views. - **Properties panel** — a selection-following webview that shows the full properties of the entry selected in the table. It lives in its own view container and can be docked in the secondary sidebar. - **Variable Editor for matrix values** — a value with two or more dimensions stays a short descriptor in its cell (`<2x3x2 double>`) with a grid glyph beside it; clicking the glyph opens the whole array in a floating spreadsheet-style grid, laid out the way MATLAB displays it. Anything above rank 2 gets a `(:,:,k)` page selector to step through its trailing dimensions. Available from both the table and the Properties panel; view-only. -- **Search** — filter entries by name using the table's built-in filter bar as you type, or search across every data source in the workspace with **Data Explorer: Search Data Source Entries** (Ctrl/Cmd+Alt+E), which lists each match with the file it comes from. A model's blocks are listed one hit per block, qualified by the subsystem the block sits in — so the several blocks named `Gain` a model may hold stay distinguishable, and the subsystem name is searchable too. +- **Search** — filter entries with the table's built-in filter bar. Type a word and press Enter; each condition becomes a chip you can remove with its `×`. Scope a condition to one column by naming that column's header — `Name:gain`, `"Data Type"=double`, `Value>10` — or right-click any column header to build the same thing from a popup, which shows you the text it writes. The operators are `:` (contains), `=`, `!=` (also `~=`), `>`, `<`, `>=` and `<=`; quote anything containing a space. Or search across every data source in the workspace with **Data Explorer: Search Data Source Entries** (Ctrl/Cmd+Alt+E), which lists each match with the file it comes from. A model's blocks are listed one hit per block, qualified by the subsystem the block sits in — so the several blocks named `Gain` a model may hold stay distinguishable, and the subsystem name is searchable too. + > Quoting now only groups words: `value:"5"` matches any value *containing* 5. To ask for exactly 5, use `Value=5`. - **Usage column, both directions** — a dictionary entry, MAT variable, or model-workspace variable lists the blocks that read it, qualified by the model they are in; a block's row shows which of its parameters resolved where (`Gain=Kp (params.sldd)`). Either link navigates to the other side. Resolution follows MATLAB: the mask parameters of the masked subsystems a block sits inside come first, then the model workspace, then the linked data dictionary and any dictionary it references, then linked MAT-files — so a `Gain = g1` inside a mask reads as the mask's own `g1` (`Gain=g1 (MulAdd)`), and the value that mask parameter was given is credited to the masked block. - **Block paths in the table** — where a model's blocks share a name, each row's Name shows the subsystem it lives in (`Gain (Controller)`), and hovering a block in the Usage column shows that block's full path.