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:
2026-05-28 22:30:38 -04:00
parent 8c0383ab7f
commit 6056aa9632
+102 -115
View File
@@ -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>
); );