fix: search bar layout, autocomplete FocusTrap, and from: regex
Three bugs introduced in af3155e1:
1. Layout: extra Box wrapper around Input wasn't stretching to full
width. Removed the wrapper — Input is now a direct PopOut child,
restoring its original full-width flex behaviour.
2. FocusTrap: the autocomplete dropdown had a FocusTrap that immediately
deactivated because the search input (outside the trap) was focused.
Removed the FocusTrap entirely; onMouseDown+preventDefault on each
suggestion item already prevents input blur on click, and onBlur
with a 120ms delay handles dismissal when clicking truly outside.
3. from: regex: @ was required (from:@user) but users naturally type
from:user without it. Updated FROM_REGEX and FROM_TYPING_REGEX to
make @ optional; userId construction already prepends @ if missing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,6 @@ import React, {
|
|||||||
RefObject,
|
RefObject,
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -22,17 +21,15 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { UserAvatar } from '../../components/user-avatar';
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
|
||||||
// Matches "from:@anything" anywhere in the query (no trailing space required)
|
// Matches "from:@?anything" anywhere — @ is optional so "from:jared" also works
|
||||||
const FROM_REGEX = /from:@([^\s]*)/gi;
|
const FROM_REGEX = /from:@?([^\s:][^\s]*)/gi;
|
||||||
// For detecting active typing: matches the trailing "from:@..." at cursor end
|
// Matches trailing "from:@?..." so autocomplete fires as the user types
|
||||||
const FROM_TYPING_REGEX = /from:@([^\s]*)$/i;
|
const FROM_TYPING_REGEX = /from:@?([^\s]*)$/i;
|
||||||
|
|
||||||
type SearchInputProps = {
|
type SearchInputProps = {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
@@ -52,7 +49,6 @@ export function SearchInput({
|
|||||||
}: SearchInputProps) {
|
}: SearchInputProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const inputWrapRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const [fromQuery, setFromQuery] = useState('');
|
const [fromQuery, setFromQuery] = useState('');
|
||||||
const [autocompleteAnchor, setAutocompleteAnchor] = useState<RectCords>();
|
const [autocompleteAnchor, setAutocompleteAnchor] = useState<RectCords>();
|
||||||
@@ -85,7 +81,6 @@ export function SearchInput({
|
|||||||
}
|
}
|
||||||
closeAutocomplete();
|
closeAutocomplete();
|
||||||
onSenderAdd?.(userId);
|
onSenderAdd?.(userId);
|
||||||
// Return focus to input so user can keep typing
|
|
||||||
searchInputRef.current?.focus();
|
searchInputRef.current?.focus();
|
||||||
},
|
},
|
||||||
[searchInputRef, onSenderAdd, closeAutocomplete],
|
[searchInputRef, onSenderAdd, closeAutocomplete],
|
||||||
@@ -96,13 +91,19 @@ export function SearchInput({
|
|||||||
const match = FROM_TYPING_REGEX.exec(value);
|
const match = FROM_TYPING_REGEX.exec(value);
|
||||||
if (match) {
|
if (match) {
|
||||||
setFromQuery(match[1]);
|
setFromQuery(match[1]);
|
||||||
const rect = inputWrapRef.current?.getBoundingClientRect();
|
// Anchor the dropdown to the input element itself
|
||||||
|
const rect = searchInputRef.current?.getBoundingClientRect();
|
||||||
if (rect) setAutocompleteAnchor(rect);
|
if (rect) setAutocompleteAnchor(rect);
|
||||||
} else {
|
} else {
|
||||||
closeAutocomplete();
|
closeAutocomplete();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Close autocomplete when input loses focus, but delay so item clicks fire first
|
||||||
|
const handleBlur = () => {
|
||||||
|
setTimeout(closeAutocomplete, 120);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
const { searchInput } = evt.target as HTMLFormElement & {
|
const { searchInput } = evt.target as HTMLFormElement & {
|
||||||
@@ -111,17 +112,18 @@ export function SearchInput({
|
|||||||
|
|
||||||
const rawValue = searchInput.value.trim();
|
const rawValue = searchInput.value.trim();
|
||||||
|
|
||||||
// Extract any from:@userId fragments and turn them into sender filters
|
// Extract from:user fragments and convert to sender filters
|
||||||
const fromMatches = [...rawValue.matchAll(FROM_REGEX)];
|
const fromMatches = [...rawValue.matchAll(FROM_REGEX)];
|
||||||
fromMatches.forEach((match) => {
|
fromMatches.forEach((match) => {
|
||||||
const raw = match[1];
|
const raw = match[1];
|
||||||
|
// Ensure the user ID starts with @
|
||||||
const userId = raw.startsWith('@') ? raw : `@${raw}`;
|
const userId = raw.startsWith('@') ? raw : `@${raw}`;
|
||||||
onSenderAdd?.(userId);
|
onSenderAdd?.(userId);
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchTerm = rawValue.replace(FROM_REGEX, '').replace(/\s+/g, ' ').trim() || undefined;
|
const searchTerm = rawValue.replace(FROM_REGEX, '').replace(/\s+/g, ' ').trim() || undefined;
|
||||||
|
|
||||||
// Clean up the from: fragments from the visible input
|
// Remove the from: fragments from the visible input
|
||||||
if (fromMatches.length > 0 && searchInputRef.current) {
|
if (fromMatches.length > 0 && searchInputRef.current) {
|
||||||
searchInputRef.current.value = searchTerm ?? '';
|
searchInputRef.current.value = searchTerm ?? '';
|
||||||
}
|
}
|
||||||
@@ -130,6 +132,54 @@ export function SearchInput({
|
|||||||
closeAutocomplete();
|
closeAutocomplete();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const autocompleteContent =
|
||||||
|
suggestedUsers.length > 0 ? (
|
||||||
|
<Menu variant="Surface" style={{ minWidth: '260px', maxWidth: '340px' }}>
|
||||||
|
<Box direction="Column" style={{ padding: `${config.space.S100} 0` }}>
|
||||||
|
{suggestedUsers.map((user) => {
|
||||||
|
const displayName = user.displayName ?? getMxIdLocalPart(user.userId);
|
||||||
|
const avatarUrl = user.avatarUrl
|
||||||
|
? (mxcUrlToHttp(mx, user.avatarUrl, useAuthentication, 32, 32, 'crop') ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={user.userId}
|
||||||
|
variant="Surface"
|
||||||
|
size="300"
|
||||||
|
radii="0"
|
||||||
|
// Prevent input blur when clicking a suggestion
|
||||||
|
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
|
||||||
|
onClick={() => handleUserSelect(user.userId)}
|
||||||
|
before={
|
||||||
|
<Avatar size="200" radii="Pill">
|
||||||
|
<UserAvatar
|
||||||
|
userId={user.userId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={displayName ?? user.userId}
|
||||||
|
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box direction="Column" style={{ minWidth: 0 }}>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
truncate
|
||||||
|
style={{ opacity: 0.6, fontFamily: 'monospace', fontSize: '0.75em' }}
|
||||||
|
>
|
||||||
|
{user.userId}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
|
<Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
|
||||||
<span data-spacing-node />
|
<span data-spacing-node />
|
||||||
@@ -139,110 +189,47 @@ export function SearchInput({
|
|||||||
position="Bottom"
|
position="Bottom"
|
||||||
align="Start"
|
align="Start"
|
||||||
offset={4}
|
offset={4}
|
||||||
content={
|
content={autocompleteContent}
|
||||||
suggestedUsers.length > 0 ? (
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: closeAutocomplete,
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
allowOutsideClick: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu variant="Surface" style={{ minWidth: '240px', maxWidth: '320px' }}>
|
|
||||||
<Box direction="Column" style={{ padding: `${config.space.S100} 0` }}>
|
|
||||||
{suggestedUsers.map((user) => {
|
|
||||||
const displayName = user.displayName ?? getMxIdLocalPart(user.userId);
|
|
||||||
const avatarUrl = user.avatarUrl
|
|
||||||
? (mxcUrlToHttp(mx, user.avatarUrl, useAuthentication, 32, 32, 'crop') ??
|
|
||||||
undefined)
|
|
||||||
: undefined;
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
key={user.userId}
|
|
||||||
variant="Surface"
|
|
||||||
size="300"
|
|
||||||
radii="0"
|
|
||||||
// Prevent blur on the search input when clicking
|
|
||||||
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
|
|
||||||
onClick={() => handleUserSelect(user.userId)}
|
|
||||||
before={
|
|
||||||
<Avatar size="200" radii="Pill">
|
|
||||||
<UserAvatar
|
|
||||||
userId={user.userId}
|
|
||||||
src={avatarUrl}
|
|
||||||
alt={displayName ?? user.userId}
|
|
||||||
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
|
|
||||||
/>
|
|
||||||
</Avatar>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box direction="Column" style={{ minWidth: 0 }}>
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
{displayName}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
size="T200"
|
|
||||||
truncate
|
|
||||||
style={{
|
|
||||||
opacity: 0.6,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '0.75em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.userId}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Menu>
|
|
||||||
</FocusTrap>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Box ref={inputWrapRef}>
|
<Input
|
||||||
<Input
|
ref={searchInputRef}
|
||||||
ref={searchInputRef}
|
style={{ paddingRight: config.space.S300 }}
|
||||||
style={{ paddingRight: config.space.S300 }}
|
name="searchInput"
|
||||||
name="searchInput"
|
autoFocus
|
||||||
autoFocus
|
size="500"
|
||||||
size="500"
|
variant="Background"
|
||||||
variant="Background"
|
placeholder="Search messages or type from:@user to filter by sender"
|
||||||
placeholder="Search messages or type from:@user to filter by sender"
|
autoComplete="off"
|
||||||
autoComplete="off"
|
onChange={handleChange}
|
||||||
onChange={handleChange}
|
onBlur={handleBlur}
|
||||||
before={
|
before={
|
||||||
active && loading ? (
|
active && loading ? (
|
||||||
<Spinner variant="Secondary" size="200" />
|
<Spinner variant="Secondary" size="200" />
|
||||||
) : (
|
) : (
|
||||||
<Icon size="200" src={Icons.Search} />
|
<Icon size="200" src={Icons.Search} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
after={
|
after={
|
||||||
active ? (
|
active ? (
|
||||||
<Chip
|
<Chip
|
||||||
key="resetButton"
|
key="resetButton"
|
||||||
type="reset"
|
type="reset"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
size="400"
|
size="400"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
outlined
|
outlined
|
||||||
after={<Icon size="50" src={Icons.Cross} />}
|
after={<Icon size="50" src={Icons.Cross} />}
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
>
|
>
|
||||||
<Text size="B300">Clear</Text>
|
<Text size="B300">Clear</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
) : (
|
) : (
|
||||||
<Chip type="submit" variant="Primary" size="400" radii="Pill" outlined>
|
<Chip type="submit" variant="Primary" size="400" radii="Pill" outlined>
|
||||||
<Text size="B300">Enter</Text>
|
<Text size="B300">Enter</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
|
||||||
</PopOut>
|
</PopOut>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user