Enhance FilterInput component to support wildcard highlighting and improve caret positioning
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Added support for wildcard symbols (*, ?) in the FilterInput component, allowing for in-place highlighting.
- Updated the caret positioning logic to handle wildcard spans correctly, ensuring accurate text selection.
- Refactored tokenization and rendering functions to accommodate new wildcard types, enhancing overall functionality.
This commit is contained in:
Tom Butcher 2026-08-11 01:54:26 +01:00
parent 0723e8d73b
commit b85089604f

View File

@ -29,12 +29,14 @@ import EqualIcon from '../../Icons/EqualIcon'
const operandIconStyle = { fontSize: 8 } const operandIconStyle = { fontSize: 8 }
// Longer symbols first so ".." / "<>" / ">=" match before "." / "<" / ">". // Longer symbols first so ".." / "<>" / ">=" match before "." / "<" / ">".
// Wildcards (* ?) and @ stay as plain text they are not operands. // Wildcards (* ?) are highlighted in place; @ stays as plain text none are operands.
const WILDCARDS = new Set(['*', '?'])
const OPERANDS = [ const OPERANDS = [
{ {
symbol: '..', symbol: '..',
label: <ArrowLeftIcon style={operandIconStyle} />, label: <ArrowLeftIcon style={operandIconStyle} />,
color: 'blue' color: 'purple'
}, },
{ {
symbol: '<>', symbol: '<>',
@ -93,6 +95,13 @@ const tokenize = (raw = '') => {
} }
tokens.push({ type: 'operand', value: match.symbol }) tokens.push({ type: 'operand', value: match.symbol })
i += match.symbol.length i += match.symbol.length
} else if (WILDCARDS.has(value[i])) {
if (text) {
tokens.push({ type: 'text', value: text })
text = ''
}
tokens.push({ type: 'wildcard', value: value[i] })
i += 1
} else { } else {
text += value[i] text += value[i]
i += 1 i += 1
@ -103,6 +112,14 @@ const tokenize = (raw = '') => {
return tokens return tokens
} }
const renderWildcardNode = (char) => {
const span = document.createElement('span')
span.setAttribute('data-wildcard', char)
span.style.color = 'var(--color-primary)'
span.textContent = char
return span
}
const normalize = (raw = '') => const normalize = (raw = '') =>
tokenize(raw) tokenize(raw)
.map((token) => token.value) .map((token) => token.value)
@ -302,32 +319,41 @@ const setCaretOffset = (root, targetOffset) => {
scrollSelectionIntoView(root) scrollSelectionIntoView(root)
} }
for (let i = 0; i < root.childNodes.length; i += 1) { const walk = (container) => {
const child = root.childNodes[i] for (let i = 0; i < container.childNodes.length; i += 1) {
const length = nodeLength(child) const child = container.childNodes[i]
const length = nodeLength(child)
if (child.nodeType === Node.TEXT_NODE) { if (child.nodeType === Node.TEXT_NODE) {
if (remaining <= length) { if (remaining <= length) {
place(child, remaining) place(child, remaining)
return return true
}
remaining -= length
continue
} }
remaining -= length
continue
}
if (child.getAttribute?.('data-operand') != null) { if (child.getAttribute?.('data-operand') != null) {
if (remaining === 0) { if (remaining === 0) {
place(root, i) place(container, i)
return return true
}
if (remaining <= length) {
place(container, i + 1)
return true
}
remaining -= length
continue
} }
if (remaining <= length) {
place(root, i + 1) // Element wrappers (e.g. wildcard spans): recurse into children.
return if (walk(child)) return true
}
remaining -= length
} }
return false
} }
if (walk(root)) return
const range = document.createRange() const range = document.createRange()
range.selectNodeContents(root) range.selectNodeContents(root)
range.collapse(false) range.collapse(false)
@ -341,7 +367,10 @@ const domMatchesTokens = (root, raw) => {
const tokens = tokenize(raw) const tokens = tokenize(raw)
const children = Array.from(root.childNodes).filter((node) => { const children = Array.from(root.childNodes).filter((node) => {
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? '') !== '' if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? '') !== ''
return node.getAttribute?.('data-operand') != null return (
node.getAttribute?.('data-operand') != null ||
node.getAttribute?.('data-wildcard') != null
)
}) })
// Empty editor may keep a single empty text node. // Empty editor may keep a single empty text node.
@ -363,6 +392,12 @@ const domMatchesTokens = (root, raw) => {
child.nodeType === Node.TEXT_NODE && child.textContent === token.value child.nodeType === Node.TEXT_NODE && child.textContent === token.value
) )
} }
if (token.type === 'wildcard') {
return (
child.getAttribute?.('data-wildcard') === token.value &&
(child.textContent ?? '') === token.value
)
}
return child.getAttribute?.('data-operand') === token.value return child.getAttribute?.('data-operand') === token.value
}) })
} }
@ -569,6 +604,8 @@ const FilterInput = ({
for (const item of tokens) { for (const item of tokens) {
if (item.type === 'text') { if (item.type === 'text') {
nextChildren.push(document.createTextNode(item.value)) nextChildren.push(document.createTextNode(item.value))
} else if (item.type === 'wildcard') {
nextChildren.push(renderWildcardNode(item.value))
} else { } else {
nextChildren.push(takeChip(item.value)) nextChildren.push(takeChip(item.value))
} }