Add FilterInput component for enhanced filtering capabilities
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit

- Introduced a new FilterInput component to streamline user input for filter values, replacing the standard Input component in FilterSidebar and ObjectTable.
- Updated FilterSidebar and ObjectTable to utilize FilterInput, improving the overall filtering experience and consistency across the dashboard.
- Enhanced expression handling in ColumnFilterDropdown to accommodate the new FilterInput component, ensuring seamless integration with existing functionality.
This commit is contained in:
Tom Butcher 2026-08-11 00:38:06 +01:00
parent fdd7a09d84
commit 6b848d392c
3 changed files with 998 additions and 8 deletions

View File

@ -0,0 +1,990 @@
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { ConfigProvider, Tag, theme } from 'antd'
import { CloseCircleFilled } from '@ant-design/icons'
import PropTypes from 'prop-types'
import { createRoot } from 'react-dom/client'
import classNames from 'classnames'
import useCSSVarCls from 'antd/es/config-provider/hooks/useCSSVarCls'
import useSize from 'antd/es/config-provider/hooks/useSize'
import { useCompactItemContext } from 'antd/es/space/Compact'
import useStyle, { useSharedStyle } from 'antd/es/input/style'
import { useThemeContext } from '../context/ThemeContext'
// Longer symbols first so ".." / "<>" / ">=" match before "." / "<" / ">".
// Wildcards (* ?) and @ stay as plain text they are not operands.
const OPERANDS = [
{ symbol: '..', label: 'TO', color: 'cyan' },
{ symbol: '<>', label: '≠', color: 'volcano' },
{ symbol: '>=', label: '> OR =', color: 'orange' },
{ symbol: '<=', label: '< OR =', color: 'orange' },
{ symbol: '|', label: 'OR', color: 'purple' },
{ symbol: '&', label: 'AND', color: 'purple' },
{ symbol: '>', label: '>', color: 'orange' },
{ symbol: '<', label: '<', color: 'orange' },
{ symbol: '=', label: '=', color: 'blue' }
]
const OPERAND_BY_SYMBOL = Object.fromEntries(
OPERANDS.map((operand) => [operand.symbol, operand])
)
const tokenize = (raw = '') => {
const value = String(raw)
const tokens = []
let text = ''
let i = 0
while (i < value.length) {
const match = OPERANDS.find((operand) =>
value.startsWith(operand.symbol, i)
)
if (match) {
if (text) {
tokens.push({ type: 'text', value: text })
text = ''
}
tokens.push({ type: 'operand', value: match.symbol })
i += match.symbol.length
} else {
text += value[i]
i += 1
}
}
if (text) tokens.push({ type: 'text', value: text })
return tokens
}
const normalize = (raw = '') =>
tokenize(raw)
.map((token) => token.value)
.join('')
const nodeLength = (node) => {
if (!node) return 0
if (node.nodeType === Node.TEXT_NODE) return node.textContent?.length ?? 0
const operand = node.getAttribute?.('data-operand')
if (operand != null) return operand.length
return Array.from(node.childNodes).reduce(
(sum, child) => sum + nodeLength(child),
0
)
}
const serializeNode = (root) => {
if (!root) return ''
let result = ''
for (const node of root.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
result += node.textContent ?? ''
} else if (node.getAttribute?.('data-operand') != null) {
result += node.getAttribute('data-operand')
} else {
result += serializeNode(node)
}
}
return result
}
/** Character offset in the serialized filter string for a DOM point. */
const getOffsetAt = (root, targetNode, targetOffset, chipSnap = 'end') => {
if (!root || !targetNode || !root.contains(targetNode)) {
return serializeNode(root).length
}
let offset = 0
const visit = (node) => {
if (node === targetNode) {
if (node.nodeType === Node.TEXT_NODE) {
offset += targetOffset
} else if (node.getAttribute?.('data-operand') != null) {
offset += targetOffset <= 0 ? 0 : node.getAttribute('data-operand').length
} else {
for (let i = 0; i < targetOffset; i += 1) {
offset += nodeLength(node.childNodes[i])
}
}
return true
}
if (node.nodeType === Node.TEXT_NODE) {
offset += node.textContent?.length ?? 0
return false
}
if (node.getAttribute?.('data-operand') != null) {
if (node.contains(targetNode)) {
// Chips are atomic: snap range starts to the chip start, ends to the chip end.
if (chipSnap !== 'start') {
offset += node.getAttribute('data-operand').length
}
return true
}
offset += node.getAttribute('data-operand').length
return false
}
for (const child of node.childNodes) {
if (visit(child)) return true
}
return false
}
visit(root)
return offset
}
/** Character offset of the selection focus (caret). */
const getCaretOffset = (root) => {
const selection = window.getSelection()
if (!selection?.rangeCount || !root.contains(selection.focusNode)) {
return serializeNode(root).length
}
return getOffsetAt(root, selection.focusNode, selection.focusOffset, 'end')
}
/** Serialized filter text for the current selection (operand symbols, not labels). */
const getSelectedSerializedText = (root) => {
const selection = window.getSelection()
if (!selection?.rangeCount || selection.isCollapsed) return ''
const range = selection.getRangeAt(0)
if (
!root.contains(range.startContainer) ||
!root.contains(range.endContainer)
) {
return null
}
const start = getOffsetAt(
root,
range.startContainer,
range.startOffset,
'start'
)
const end = getOffsetAt(root, range.endContainer, range.endOffset, 'end')
return serializeNode(root).slice(Math.min(start, end), Math.max(start, end))
}
/** Keep the caret / selection focus visible inside a horizontally clipped editor. */
const scrollPointIntoView = (root, clientX) => {
if (!root || clientX == null || Number.isNaN(clientX)) return
const rect = root.getBoundingClientRect()
const pad = 8
if (clientX > rect.right - pad) {
root.scrollLeft += clientX - (rect.right - pad)
} else if (clientX < rect.left + pad) {
root.scrollLeft -= rect.left + pad - clientX
}
}
const scrollSelectionIntoView = (root) => {
if (!root) return
const selection = window.getSelection()
if (!selection?.rangeCount || !root.contains(selection.focusNode)) return
try {
const range = document.createRange()
range.setStart(selection.focusNode, selection.focusOffset)
range.collapse(true)
const rects = range.getClientRects()
const rect = rects[rects.length - 1] ?? range.getBoundingClientRect()
if (rect && (rect.width > 0 || rect.height > 0)) {
scrollPointIntoView(root, rect.left)
return
}
} catch {
// Fall through to caret-at-end heuristic.
}
// Empty / collapsed-at-end selections often have a zero rect scroll to end.
if (getCaretOffset(root) >= serializeNode(root).length) {
root.scrollLeft = root.scrollWidth
}
}
const caretRangeFromPoint = (clientX, clientY) => {
if (document.caretRangeFromPoint) {
return document.caretRangeFromPoint(clientX, clientY)
}
if (document.caretPositionFromPoint) {
const pos = document.caretPositionFromPoint(clientX, clientY)
if (!pos) return null
const range = document.createRange()
range.setStart(pos.offsetNode, pos.offset)
range.collapse(true)
return range
}
return null
}
/** Extend the live selection to the DOM point under the pointer (used while edge-scrolling). */
const extendSelectionToPoint = (root, clientX, clientY) => {
const selection = window.getSelection()
if (!selection?.rangeCount || !root) return
const caret = caretRangeFromPoint(clientX, clientY)
if (!caret || !root.contains(caret.startContainer)) return
try {
selection.setBaseAndExtent(
selection.anchorNode,
selection.anchorOffset,
caret.startContainer,
caret.startOffset
)
} catch {
// Ignore invalid selection boundaries (e.g. inside non-editable chips).
}
}
const setCaretOffset = (root, targetOffset) => {
if (!root) return
const selection = window.getSelection()
if (!selection) return
let remaining = Math.max(0, targetOffset)
const place = (node, offset) => {
const range = document.createRange()
range.setStart(node, offset)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
scrollSelectionIntoView(root)
}
for (let i = 0; i < root.childNodes.length; i += 1) {
const child = root.childNodes[i]
const length = nodeLength(child)
if (child.nodeType === Node.TEXT_NODE) {
if (remaining <= length) {
place(child, remaining)
return
}
remaining -= length
continue
}
if (child.getAttribute?.('data-operand') != null) {
if (remaining === 0) {
place(root, i)
return
}
if (remaining <= length) {
place(root, i + 1)
return
}
remaining -= length
}
}
const range = document.createRange()
range.selectNodeContents(root)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
scrollSelectionIntoView(root)
}
/** True when the DOM already mirrors tokenize(serialized). */
const domMatchesTokens = (root, raw) => {
const tokens = tokenize(raw)
const children = Array.from(root.childNodes).filter((node) => {
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? '') !== ''
return node.getAttribute?.('data-operand') != null
})
// Empty editor may keep a single empty text node.
if (tokens.length === 0) {
return (
root.childNodes.length === 0 ||
(root.childNodes.length === 1 &&
root.childNodes[0].nodeType === Node.TEXT_NODE &&
(root.childNodes[0].textContent ?? '') === '')
)
}
if (children.length !== tokens.length) return false
return tokens.every((token, index) => {
const child = children[index]
if (token.type === 'text') {
return (
child.nodeType === Node.TEXT_NODE && child.textContent === token.value
)
}
return child.getAttribute?.('data-operand') === token.value
})
}
/** Operand chips are only "live" when their portal React root is still mounted. */
const chipsHaveLiveRoots = (root, roots) => {
for (const node of root.childNodes) {
if (node.getAttribute?.('data-operand') == null) continue
if (!roots.has(node)) return false
}
return true
}
const FilterInput = ({
value = '',
onChange,
placeholder = 'Filter…',
disabled = false,
allowClear = false,
className = '',
style,
onFocus,
onBlur,
onPressEnter,
size
}) => {
const { getPrefixCls, direction } = useContext(ConfigProvider.ConfigContext)
const prefixCls = getPrefixCls('input')
const { token } = theme.useToken()
const { themeConfig } = useThemeContext()
const themeConfigRef = useRef(themeConfig)
themeConfigRef.current = themeConfig
const rootCls = useCSSVarCls(prefixCls)
const [wrapSharedCSSVar, hashId, cssVarCls] = useSharedStyle(prefixCls)
const [wrapCSSVar] = useStyle(prefixCls, rootCls)
const { compactSize, compactItemClassnames } = useCompactItemContext(
prefixCls,
direction
)
const mergedSize = useSize((ctx) => size ?? compactSize ?? ctx)
const editorRef = useRef(null)
const tagRootsRef = useRef(new Map())
const focusedRef = useRef(false)
const composingRef = useRef(false)
const selectingRef = useRef(false)
const valueRef = useRef(value ?? '')
const [focused, setFocused] = useState(false)
const [internalValue, setInternalValue] = useState(value ?? '')
const clearTagRoots = useCallback(() => {
tagRootsRef.current.forEach((root) => {
queueMicrotask(() => root.unmount())
})
tagRootsRef.current.clear()
}, [])
const unmountChip = useCallback((wrapper) => {
const root = tagRootsRef.current.get(wrapper)
if (root) {
queueMicrotask(() => root.unmount())
tagRootsRef.current.delete(wrapper)
}
}, [])
const renderTagNode = useCallback((symbol, themeValue) => {
const operand = OPERAND_BY_SYMBOL[symbol]
return (
<ConfigProvider theme={themeValue}>
<Tag
color={operand?.color ?? 'default'}
bordered={false}
style={{
marginInlineEnd: 0,
marginRight: 0,
height: '18px',
cursor: 'default',
padding: '0 4px',
marginTop: '-2px',
lineHeight: 1.37,
userSelect: 'none',
pointerEvents: 'none',
flexShrink: 0,
fontWeight: 600
}}
>
{operand?.label ?? symbol}
</Tag>
</ConfigProvider>
)
}, [])
const renderOperandChip = useCallback(
(symbol) => {
const wrapper = document.createElement('span')
wrapper.setAttribute('data-operand', symbol)
wrapper.setAttribute('contenteditable', 'false')
wrapper.style.display = 'inline-flex'
wrapper.style.alignItems = 'center'
wrapper.style.verticalAlign = 'middle'
wrapper.style.margin = '0 2px'
wrapper.style.userSelect = 'none'
wrapper.style.flexShrink = '0'
const root = createRoot(wrapper)
root.render(renderTagNode(symbol, themeConfigRef.current))
tagRootsRef.current.set(wrapper, root)
return wrapper
},
[renderTagNode]
)
// Portal Tags sit outside the React tree re-render them when theme changes.
useEffect(() => {
tagRootsRef.current.forEach((root, wrapper) => {
root.render(
renderTagNode(wrapper.getAttribute('data-operand'), themeConfig)
)
})
}, [themeConfig, renderTagNode])
const emitChange = useCallback(
(next) => {
valueRef.current = next
setInternalValue(next)
onChange?.(next)
},
[onChange]
)
const paint = useCallback(
(raw, caret = null) => {
const editor = editorRef.current
if (!editor) return
const normalized = normalize(raw)
// Already in sync with live chip roots skip to avoid a tag flash.
if (
domMatchesTokens(editor, normalized) &&
chipsHaveLiveRoots(editor, tagRootsRef.current)
) {
if (caret != null && focusedRef.current) {
requestAnimationFrame(() => {
setCaretOffset(editor, caret)
scrollSelectionIntoView(editor)
})
}
return
}
const scrollLeft = editor.scrollLeft
const tokens = tokenize(normalized)
// Reuse operand chips that still have a mounted React root.
const chipPool = new Map()
for (const node of Array.from(editor.childNodes)) {
const symbol = node.getAttribute?.('data-operand')
if (symbol == null) continue
if (!tagRootsRef.current.has(node)) continue
const list = chipPool.get(symbol)
if (list) list.push(node)
else chipPool.set(symbol, [node])
}
const takeChip = (symbol) => {
const list = chipPool.get(symbol)
while (list?.length) {
const wrapper = list.shift()
if (tagRootsRef.current.has(wrapper)) return wrapper
}
return renderOperandChip(symbol)
}
const nextChildren = []
if (tokens.length === 0) {
nextChildren.push(document.createTextNode(''))
} else {
for (const item of tokens) {
if (item.type === 'text') {
nextChildren.push(document.createTextNode(item.value))
} else {
nextChildren.push(takeChip(item.value))
}
}
}
chipPool.forEach((list) => {
list.forEach((wrapper) => unmountChip(wrapper))
})
editor.replaceChildren(...nextChildren)
editor.scrollLeft = scrollLeft
if (caret != null && focusedRef.current) {
requestAnimationFrame(() => {
setCaretOffset(editor, caret)
scrollSelectionIntoView(editor)
})
}
},
[renderOperandChip, unmountChip]
)
useEffect(() => {
const next = value ?? ''
valueRef.current = next
setInternalValue(next)
if (!focusedRef.current) {
paint(next)
}
}, [value, paint])
useEffect(() => () => clearTagRoots(), [clearTagRoots])
const syncFromDom = useCallback(() => {
const editor = editorRef.current
if (!editor) return
const caret = getCaretOffset(editor)
const serialized = serializeNode(editor)
const normalized = normalize(serialized)
if (!domMatchesTokens(editor, normalized)) {
paint(normalized, caret)
} else {
scrollSelectionIntoView(editor)
}
if (normalized !== valueRef.current) {
emitChange(normalized)
}
}, [emitChange, paint])
const findOperandWrapper = (node) => {
const editor = editorRef.current
let current = node
while (current && current !== editor) {
if (current.getAttribute?.('data-operand') != null) return current
current = current.parentNode
}
return null
}
const handleInput = () => {
if (composingRef.current || disabled) return
syncFromDom()
}
const removeChip = (chip) => {
const editor = editorRef.current
if (!editor || !chip) return
const parent = chip.parentNode
const index = Array.from(parent.childNodes).indexOf(chip)
let offset = 0
for (let i = 0; i < index; i += 1) {
offset += nodeLength(parent.childNodes[i])
}
const root = tagRootsRef.current.get(chip)
if (root) {
queueMicrotask(() => root.unmount())
tagRootsRef.current.delete(chip)
}
parent.removeChild(chip)
setCaretOffset(editor, offset)
syncFromDom()
}
const chipBeforeCaret = () => {
const editor = editorRef.current
const selection = window.getSelection()
if (!editor || !selection?.rangeCount || !selection.isCollapsed) return null
const { startContainer, startOffset } = selection.getRangeAt(0)
const insideOperand = findOperandWrapper(startContainer)
if (insideOperand) return insideOperand
if (startContainer === editor && startOffset > 0) {
const prev = editor.childNodes[startOffset - 1]
if (prev?.getAttribute?.('data-operand') != null) return prev
}
if (startContainer.nodeType === Node.TEXT_NODE && startOffset === 0) {
const prev = startContainer.previousSibling
if (prev?.getAttribute?.('data-operand') != null) return prev
}
return null
}
const chipAfterCaret = () => {
const editor = editorRef.current
const selection = window.getSelection()
if (!editor || !selection?.rangeCount || !selection.isCollapsed) return null
const { startContainer, startOffset } = selection.getRangeAt(0)
const insideOperand = findOperandWrapper(startContainer)
if (insideOperand) return insideOperand
if (startContainer === editor) {
const next = editor.childNodes[startOffset]
if (next?.getAttribute?.('data-operand') != null) return next
}
if (
startContainer.nodeType === Node.TEXT_NODE &&
startOffset === (startContainer.textContent?.length ?? 0)
) {
const next = startContainer.nextSibling
if (next?.getAttribute?.('data-operand') != null) return next
}
return null
}
const handleKeyDown = (event) => {
if (disabled) return
if (event.key === 'Enter') {
event.preventDefault()
onPressEnter?.(internalValue)
return
}
if (event.key === 'Backspace') {
const chip = chipBeforeCaret()
if (chip) {
event.preventDefault()
removeChip(chip)
}
return
}
if (event.key === 'Delete') {
const chip = chipAfterCaret()
if (chip) {
event.preventDefault()
removeChip(chip)
}
}
}
const handlePaste = (event) => {
if (disabled) return
event.preventDefault()
const text = (event.clipboardData?.getData('text/plain') ?? '').replace(
/[\r\n]+/g,
''
)
const editor = editorRef.current
if (!editor) return
const selection = window.getSelection()
if (!selection?.rangeCount) return
const range = selection.getRangeAt(0)
const start = getOffsetAt(
editor,
range.startContainer,
range.startOffset,
'start'
)
const end = getOffsetAt(editor, range.endContainer, range.endOffset, 'end')
const from = Math.min(start, end)
const to = Math.max(start, end)
const current = serializeNode(editor)
const next = `${current.slice(0, from)}${text}${current.slice(to)}`
const normalized = normalize(next)
paint(normalized, from + text.length)
emitChange(normalized)
}
const handleCopy = (event) => {
const editor = editorRef.current
if (!editor) return
const selection = window.getSelection()
if (!selection?.rangeCount || selection.isCollapsed) return
const text = getSelectedSerializedText(editor)
if (text == null) return
event.preventDefault()
event.clipboardData?.setData('text/plain', text)
}
const handleCut = (event) => {
if (disabled) return
const editor = editorRef.current
if (!editor) return
const selection = window.getSelection()
if (!selection?.rangeCount || selection.isCollapsed) return
const text = getSelectedSerializedText(editor)
if (text == null) return
event.preventDefault()
event.clipboardData?.setData('text/plain', text)
const range = selection.getRangeAt(0)
const start = getOffsetAt(
editor,
range.startContainer,
range.startOffset,
'start'
)
const end = getOffsetAt(editor, range.endContainer, range.endOffset, 'end')
const from = Math.min(start, end)
const to = Math.max(start, end)
const current = serializeNode(editor)
const next = `${current.slice(0, from)}${current.slice(to)}`
const normalized = normalize(next)
paint(normalized, from)
emitChange(normalized)
}
const handleClear = (event) => {
event.preventDefault()
event.stopPropagation()
if (disabled) return
paint('')
emitChange('')
editorRef.current?.focus()
}
// Auto-scroll while click-dragging a selection past the visible edges.
useEffect(() => {
let raf = 0
let lastClientX = null
const stopRaf = () => {
if (raf) {
cancelAnimationFrame(raf)
raf = 0
}
}
const tick = () => {
raf = 0
if (!selectingRef.current || !focusedRef.current || lastClientX == null) {
return
}
const editor = editorRef.current
if (!editor) return
const rect = editor.getBoundingClientRect()
const edge = 28
let dx = 0
if (lastClientX >= rect.right - edge) {
dx =
Math.min(28, Math.ceil((lastClientX - (rect.right - edge)) / 2) + 6)
} else if (lastClientX <= rect.left + edge) {
dx = -Math.min(
28,
Math.ceil((rect.left + edge - lastClientX) / 2) + 6
)
}
if (dx !== 0) {
const maxScroll = editor.scrollWidth - editor.clientWidth
const next = Math.max(0, Math.min(maxScroll, editor.scrollLeft + dx))
if (next !== editor.scrollLeft) {
editor.scrollLeft = next
extendSelectionToPoint(
editor,
lastClientX,
rect.top + rect.height / 2
)
scrollSelectionIntoView(editor)
raf = requestAnimationFrame(tick)
}
}
}
const onMouseMove = (event) => {
if (!selectingRef.current || !focusedRef.current) return
lastClientX = event.clientX
const editor = editorRef.current
if (!editor) return
scrollPointIntoView(editor, event.clientX)
if (!raf) raf = requestAnimationFrame(tick)
}
const onMouseUp = () => {
selectingRef.current = false
lastClientX = null
stopRaf()
}
const onSelectionChange = () => {
if (!focusedRef.current) return
const editor = editorRef.current
if (!editor) return
const selection = window.getSelection()
if (!selection?.rangeCount || !editor.contains(selection.focusNode)) return
scrollSelectionIntoView(editor)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('selectionchange', onSelectionChange)
return () => {
stopRaf()
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('selectionchange', onSelectionChange)
}
}, [])
const showPlaceholder = !internalValue && !focused
const showClear = allowClear && !!internalValue && !disabled
const affixCls = `${prefixCls}-affix-wrapper`
const placeholderPaddingInline =
mergedSize === 'large'
? (token.paddingInlineLG ?? token.paddingSM + 4)
: mergedSize === 'small'
? (token.paddingInlineSM ?? token.paddingXS)
: (token.paddingInline ?? token.paddingSM)
return wrapSharedCSSVar(
wrapCSSVar(
<span
className={classNames(
affixCls,
{
[`${affixCls}-focused`]: focused,
[`${affixCls}-disabled`]: disabled,
[`${affixCls}-sm`]: mergedSize === 'small',
[`${affixCls}-lg`]: mergedSize === 'large',
[`${affixCls}-rtl`]: direction === 'rtl',
[`${prefixCls}-outlined`]: true
},
compactItemClassnames,
cssVarCls,
rootCls,
hashId,
className
)}
style={{
width: '100%',
cursor: disabled ? 'not-allowed' : 'text',
...style,
// Affix ::before strut needs inline-flex; display:block stacks it and inflates height.
display: 'inline-flex'
}}
onClick={() => {
if (!disabled) editorRef.current?.focus()
}}
>
<style>{`
.filter-input-editor::-webkit-scrollbar {
display: none;
height: 0;
}
`}</style>
{showPlaceholder && (
<span
aria-hidden
style={{
position: 'absolute',
insetInlineStart: placeholderPaddingInline,
insetInlineEnd: showClear
? placeholderPaddingInline + 22
: placeholderPaddingInline,
top: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
color: token.colorTextPlaceholder,
pointerEvents: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
zIndex: 1
}}
>
{placeholder}
</span>
)}
<div
ref={editorRef}
className={classNames(prefixCls, 'filter-input-editor')}
role='textbox'
aria-multiline='false'
aria-disabled={disabled || undefined}
contentEditable={!disabled}
suppressContentEditableWarning
spellCheck={false}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onCopy={handleCopy}
onCut={handleCut}
onMouseDown={() => {
selectingRef.current = true
}}
onCompositionStart={() => {
composingRef.current = true
}}
onCompositionEnd={() => {
composingRef.current = false
syncFromDom()
}}
onFocus={(event) => {
focusedRef.current = true
setFocused(true)
onFocus?.(event)
}}
onBlur={(event) => {
focusedRef.current = false
selectingRef.current = false
setFocused(false)
const next = normalize(serializeNode(editorRef.current))
paint(next)
if (next !== valueRef.current) emitChange(next)
onBlur?.(event)
}}
style={{
// Match antd's `> input.ant-input` resets inside affix-wrapper
display: 'block',
padding: 0,
border: 'none',
borderRadius: 0,
outline: 'none',
boxShadow: 'none',
background: 'transparent',
flex: 'auto',
minWidth: 0,
width: '100%',
fontSize: 'inherit',
lineHeight: 'inherit',
color: 'inherit',
whiteSpace: 'nowrap',
overflowX: 'auto',
overflowY: 'hidden',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
wordBreak: 'keep-all',
overflowWrap: 'normal',
caretColor: token.colorText,
cursor: disabled ? 'not-allowed' : 'text'
}}
/>
{showClear && (
<span className={`${prefixCls}-suffix`}>
<span
className={classNames(
`${prefixCls}-clear-icon`,
`${prefixCls}-clear-icon-has-value`
)}
role='button'
tabIndex={-1}
onMouseDown={handleClear}
>
<CloseCircleFilled />
</span>
</span>
)}
</span>
)
)
}
FilterInput.propTypes = {
value: PropTypes.string,
onChange: PropTypes.func,
placeholder: PropTypes.string,
disabled: PropTypes.bool,
allowClear: PropTypes.bool,
className: PropTypes.string,
style: PropTypes.object,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
onPressEnter: PropTypes.func,
size: PropTypes.oneOf(['small', 'middle', 'large'])
}
export default FilterInput
export { OPERANDS, tokenize }

View File

@ -1,5 +1,6 @@
import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
import { Card, Select, Input, Button, Flex, Space, Dropdown } from 'antd'
import { Card, Select, Button, Flex, Space, Dropdown } from 'antd'
import FilterInput from './FilterInput'
import { CloseOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types'
import PlusIcon from '../../Icons/PlusIcon'
@ -161,10 +162,10 @@ const FilterSidebar = ({
style={{ minWidth: 80 }}
allowClear={false}
/>
<Input
<FilterInput
placeholder='Value'
value={row.value}
onChange={(e) => changeValue(row.field, e.target.value)}
onChange={(value) => changeValue(row.field, value)}
style={{ flex: 1 }}
/>
<Button

View File

@ -18,7 +18,6 @@ import {
Flex,
Spin,
Button,
Input,
Space,
Tooltip,
Form,
@ -51,6 +50,7 @@ import ActionsIcon from '../../Icons/ActionsIcon'
import FilterIcon from '../../Icons/FilterIcon'
import ScrollBox from './ScrollBox'
import SimplePropertyFilter from './SimplePropertyFilter'
import FilterInput from './FilterInput'
import {
getActiveFilterValues,
useTableStatePersistence
@ -117,8 +117,7 @@ const ColumnFilterDropdown = ({
setExpression(toFilterExpression(next) ?? '')
}
const handleExpressionChange = (e) => {
const text = e.target.value
const handleExpressionChange = (text) => {
setExpression(text)
setDraft(fromFilterExpression(text) || [])
}
@ -146,12 +145,12 @@ const ColumnFilterDropdown = ({
<div style={{ padding: 8 }}>
<Flex vertical gap='small'>
<Space.Compact>
<Input
<FilterInput
placeholder={'Filter ' + propertyLabel}
value={expression}
onChange={handleExpressionChange}
onPressEnter={applyFilter}
style={{ width: 200, display: 'block' }}
style={{ width: 200 }}
/>
<Button onClick={resetFilter} icon={<XMarkIcon />} />
<Button type='primary' onClick={applyFilter} icon={<CheckIcon />} />