Line data Source code
1 : import * as React from 'react';
2 :
3 : import { Button, ButtonSize, ButtonVariant } from '../../ui/foundations/button.js';
4 : import { SearchIcon, XIcon } from '../../ui/foundations/symbol_icon.js';
5 : import { TextInput } from '../../ui/foundations/text_input.js';
6 : import { TextInputAction } from '../../ui/foundations/text_input_action.js';
7 : import type { ResultSearchState } from '../../compute/computation_types.js';
8 : import * as styles from './query_result_search_controls.module.css';
9 :
10 : /// Data searches run SQL over the whole result, so they only start once typing pauses
11 1 : const DATA_SEARCH_DEBOUNCE_MS = 200;
12 :
13 : interface SearchControlProps {
14 : kind: 'Columns' | 'Data';
15 : state: ResultSearchState;
16 : expanded: boolean;
17 : onExpand: () => void;
18 : onCollapse: () => void;
19 : onChange: (value: string) => void;
20 : matchCount: number | null;
21 : /// Delay before a typed pattern is propagated, 0 propagates immediately
22 : debounceMs?: number;
23 : }
24 :
25 1 : function SearchControl(props: SearchControlProps) {
26 1 : const testId = `search-${props.kind.toLowerCase()}`;
27 1 : const inputRef = React.useRef<HTMLInputElement>(null);
28 1 : const buttonRef = React.useRef<HTMLButtonElement>(null);
29 1 : const inputId = React.useId();
30 1 : React.useEffect(() => {
31 1 : if (props.expanded) inputRef.current?.focus();
32 1 : }, [props.expanded]);
33 :
34 : // Keep the input responsive with a local value and propagate it debounced
35 1 : const requestedPattern = props.state.requestedPattern;
36 1 : const [pattern, setPattern] = React.useState(requestedPattern);
37 1 : const pendingChange = React.useRef<{ timer: ReturnType<typeof setTimeout>; value: string } | null>(null);
38 1 : const onChangeRef = React.useRef(props.onChange);
39 1 : onChangeRef.current = props.onChange;
40 1 : const flushChange = React.useCallback(() => {
41 1 : const pending = pendingChange.current;
42 1 : if (pending == null) return;
43 0 : clearTimeout(pending.timer);
44 0 : pendingChange.current = null;
45 0 : onChangeRef.current(pending.value);
46 1 : }, []);
47 1 : const changePattern = React.useCallback((value: string, immediate: boolean) => {
48 1 : setPattern(value);
49 1 : const pending = pendingChange.current;
50 1 : if (pending != null) {
51 0 : clearTimeout(pending.timer);
52 0 : pendingChange.current = null;
53 0 : }
54 1 : const debounceMs = props.debounceMs ?? 0;
55 1 : if (immediate || debounceMs <= 0) {
56 1 : onChangeRef.current(value);
57 1 : return;
58 1 : }
59 0 : pendingChange.current = { timer: setTimeout(flushChange, debounceMs), value };
60 1 : }, [props.debounceMs, flushChange]);
61 : // Adopt patterns that change from outside while no local edit is pending
62 1 : React.useEffect(() => {
63 1 : if (pendingChange.current == null) setPattern(requestedPattern);
64 1 : }, [requestedPattern]);
65 : // Don't drop a pending edit when the control unmounts
66 1 : React.useEffect(() => () => flushChange(), [flushChange]);
67 :
68 1 : return (
69 1 : <div className={styles.control} aria-busy={props.state.pending || undefined}>
70 1 : {props.expanded ? (
71 1 : <TextInput
72 1 : ref={inputRef}
73 1 : id={inputId}
74 1 : className={styles.input}
75 1 : aria-label={`Search ${props.kind.toLowerCase()}`}
76 1 : data-testid={`${testId}-input`}
77 1 : placeholder={`Search ${props.kind.toLowerCase()}`}
78 1 : leadingVisual={SearchIcon}
79 1 : value={pattern}
80 1 : onChange={event => changePattern(event.target.value, false)}
81 1 : onBlur={() => {
82 1 : if (pattern.length === 0) {
83 1 : props.onCollapse();
84 : }
85 1 : }}
86 1 : onKeyDown={event => {
87 1 : if (event.key === 'Escape') {
88 1 : if (pattern.length === 0) {
89 0 : event.preventDefault();
90 0 : props.onCollapse();
91 0 : requestAnimationFrame(() => buttonRef.current?.focus());
92 0 : }
93 : }
94 1 : }}
95 1 : trailingAction={(
96 1 : <TextInputAction
97 1 : aria-label={`Close ${props.kind.toLowerCase()} search`}
98 1 : aria-labelledby=""
99 1 : data-testid={`${testId}-close`}
100 1 : onClick={() => {
101 1 : changePattern('', true);
102 1 : props.onCollapse();
103 1 : requestAnimationFrame(() => buttonRef.current?.focus());
104 1 : }}
105 : >
106 1 : <XIcon />
107 1 : </TextInputAction>
108 : )}
109 1 : />
110 : ) : (
111 1 : <Button
112 1 : ref={buttonRef}
113 1 : className={styles.button}
114 1 : variant={ButtonVariant.Invisible}
115 1 : size={ButtonSize.Small}
116 1 : leadingVisual={SearchIcon}
117 1 : aria-expanded={false}
118 1 : aria-controls={inputId}
119 1 : onClick={props.onExpand}
120 1 : data-testid={`${testId}-button`}
121 1 : title={`${props.kind} search`}
122 : >
123 1 : {props.kind}
124 1 : </Button>
125 : )}
126 1 : {props.state.error != null && (
127 0 : <span className={styles.error} title={props.state.error} aria-label={`${props.kind} search failed`}>
128 : !
129 0 : </span>
130 : )}
131 1 : <span className={styles.status} role="status" data-testid={`${testId}-status`}>
132 1 : {props.state.error
133 1 : ?? (props.state.pending
134 0 : ? `Searching ${props.kind.toLowerCase()}`
135 1 : : (props.state.requestedPattern.length > 0 && props.matchCount != null
136 1 : ? `${props.kind} search complete, ${props.matchCount} matching ${props.kind.toLowerCase()}`
137 1 : : ''))}
138 1 : </span>
139 1 : </div>
140 : );
141 1 : }
142 :
143 : interface Props {
144 : columnSearch: ResultSearchState;
145 : dataSearch: ResultSearchState;
146 : onColumnPatternChange: (value: string) => void;
147 : onDataPatternChange: (value: string) => void;
148 : columnMatchCount: number | null;
149 : dataMatchCount: number | null;
150 : }
151 :
152 1 : export function QueryResultSearchControls(props: Props) {
153 1 : const [columnsExpanded, setColumnsExpanded] = React.useState(() => props.columnSearch.requestedPattern.length > 0);
154 1 : const [dataExpanded, setDataExpanded] = React.useState(() => props.dataSearch.requestedPattern.length > 0);
155 1 : return (
156 1 : <>
157 1 : <SearchControl
158 1 : kind="Columns"
159 1 : state={props.columnSearch}
160 1 : expanded={columnsExpanded}
161 1 : onExpand={() => setColumnsExpanded(expanded => !expanded)}
162 1 : onCollapse={() => setColumnsExpanded(false)}
163 1 : onChange={props.onColumnPatternChange}
164 1 : matchCount={props.columnMatchCount}
165 1 : />
166 1 : <SearchControl
167 1 : kind="Data"
168 1 : state={props.dataSearch}
169 1 : expanded={dataExpanded}
170 1 : onExpand={() => setDataExpanded(expanded => !expanded)}
171 1 : onCollapse={() => setDataExpanded(false)}
172 1 : onChange={props.onDataPatternChange}
173 1 : matchCount={props.dataMatchCount}
174 1 : debounceMs={DATA_SEARCH_DEBOUNCE_MS}
175 1 : />
176 1 : </>
177 : );
178 1 : }
|