feat: enhance global search with navigation mode and updated action hints

This commit is contained in:
Mo Tarbin
2026-08-16 17:56:50 -04:00
parent f27bb79a07
commit dae647a4be
2 changed files with 114 additions and 22 deletions

View File

@@ -41,7 +41,8 @@
"recent": "Recent", "recent": "Recent",
"empty": { "empty": {
"title": "No direct matches", "title": "No direct matches",
"subtitle": "You can still filter the task list with this search." "subtitle": "You can still filter the task list with this search.",
"actionsSubtitle": "No view or action matches. Press backspace to search everything."
}, },
"groups": { "groups": {
"tasks": "Tasks", "tasks": "Tasks",
@@ -76,7 +77,14 @@
"open": "Open", "open": "Open",
"results_one": "{{count}} result", "results_one": "{{count}} result",
"results_other": "{{count}} results", "results_other": "{{count}} results",
"typeToSearch": "Type to search" "typeToSearch": "Type to search",
"slashHint": "/ to jump to a view"
},
"modes": {
"actions": {
"label": "Go to",
"placeholder": "Jump to a view or action"
}
} }
}, },
"policyUpdate": { "policyUpdate": {

View File

@@ -162,6 +162,19 @@ const buildQuickActions = t => [
})), })),
] ]
// Typing "/" as the first character switches the palette into navigation mode,
// borrowing the slash-menu reflex from Notion and Slack. The sigil itself is
// stripped from the visible query so the mode reads as state (the chip in the
// input) rather than as syntax the person has to keep typing around.
const MODE_SIGIL = '/'
const parseMode = value => {
const text = value || ''
return text.startsWith(MODE_SIGIL)
? { mode: 'actions', term: text.slice(MODE_SIGIL.length).trimStart() }
: { mode: null, term: text }
}
const readRecents = () => { const readRecents = () => {
try { try {
return JSON.parse(localStorage.getItem(RECENTS_KEY)) || [] return JSON.parse(localStorage.getItem(RECENTS_KEY)) || []
@@ -268,7 +281,8 @@ const GlobalSearchPalette = ({
const focusInputRef = useCallback(node => { const focusInputRef = useCallback(node => {
if (node) requestAnimationFrame(() => node.focus()) if (node) requestAnimationFrame(() => node.focus())
}, []) }, [])
const [query, setQuery] = useState(initialQuery || '') const [query, setQuery] = useState(() => parseMode(initialQuery).term)
const [mode, setMode] = useState(() => parseMode(initialQuery).mode)
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const [recents] = useState(readRecents) const [recents] = useState(readRecents)
const selectedResultRef = useRef(null) const selectedResultRef = useRef(null)
@@ -317,8 +331,34 @@ const GlobalSearchPalette = ({
[documents], [documents],
) )
const searchActions = useCallback(
normalized => {
const matches = (quickActionIndex.search(normalized, { limit: 8 }) || [])
.map(match => ({ ...match.item, score: match.score ?? 1 }))
.sort((a, b) => a.score - b.score)
// An action whose title the query starts spelling out ("create la…") is
// what the person is after, so it leads. Anything matched only through
// its keywords stays below.
const leading = matches.filter(action =>
action.title.toLocaleLowerCase().startsWith(normalized),
)
return [
...leading,
...matches.filter(action => !leading.includes(action)),
]
},
[quickActionIndex],
)
const results = useMemo(() => { const results = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase() const normalized = query.trim().toLocaleLowerCase()
// Navigation mode answers only with actions — an empty term lists them all,
// which is how the sigil teaches itself the first time someone hits "/".
if (mode === 'actions') {
return normalized ? searchActions(normalized) : quickActions
}
if (!normalized) { if (!normalized) {
const currentById = new Map(documents.map(item => [item.id, item])) const currentById = new Map(documents.map(item => [item.id, item]))
const recentResults = recents const recentResults = recents
@@ -347,15 +387,9 @@ const GlobalSearchPalette = ({
}) })
.sort((a, b) => a.score - b.score), .sort((a, b) => a.score - b.score),
) )
const actionMatches = ( // Keyword-only action matches stay below the real content they share words
quickActionIndex.search(normalized, { limit: 4 }) || [] // with; title-prefix matches lead.
) const actionMatches = searchActions(normalized).slice(0, 4)
.map(match => ({ ...match.item, score: match.score ?? 1 }))
.sort((a, b) => a.score - b.score)
// An action whose title the query starts spelling out ("create la…") is
// what the person is after, so it leads. Anything matched only through its
// keywords stays below the real content it shares words with.
const leadingActions = actionMatches.filter(action => const leadingActions = actionMatches.filter(action =>
action.title.toLocaleLowerCase().startsWith(normalized), action.title.toLocaleLowerCase().startsWith(normalized),
) )
@@ -375,7 +409,16 @@ const GlobalSearchPalette = ({
route: `/chores?search=${encodeURIComponent(query.trim())}`, route: `/chores?search=${encodeURIComponent(query.trim())}`,
}, },
] ]
}, [documents, query, quickActionIndex, recents, searchIndexes, t]) }, [
documents,
mode,
query,
quickActions,
recents,
searchActions,
searchIndexes,
t,
])
// Everything except the always-present "filter the task list" fallback. // Everything except the always-present "filter the task list" fallback.
const matchCount = results.filter( const matchCount = results.filter(
@@ -395,8 +438,25 @@ const GlobalSearchPalette = ({
if (presentation === 'modal') onClose() if (presentation === 'modal') onClose()
} }
const onQueryChange = value => {
setSelectedIndex(0)
if (!mode) {
const parsed = parseMode(value)
setMode(parsed.mode)
setQuery(parsed.term)
return
}
setQuery(value)
}
const onInputKeyDown = event => { const onInputKeyDown = event => {
if (event.key === 'ArrowDown') { // Backspace on an empty term steps back out of the mode, so the sigil is
// one keystroke to enter and one to leave.
if (event.key === 'Backspace' && mode && !query) {
event.preventDefault()
setMode(null)
setSelectedIndex(0)
} else if (event.key === 'ArrowDown') {
event.preventDefault() event.preventDefault()
setSelectedIndex(index => Math.min(index + 1, results.length - 1)) setSelectedIndex(index => Math.min(index + 1, results.length - 1))
} else if (event.key === 'ArrowUp') { } else if (event.key === 'ArrowUp') {
@@ -423,13 +483,30 @@ const GlobalSearchPalette = ({
}, },
}} }}
value={query} value={query}
onChange={event => { onChange={event => onQueryChange(event.target.value)}
setQuery(event.target.value)
setSelectedIndex(0)
}}
onKeyDown={onInputKeyDown} onKeyDown={onInputKeyDown}
placeholder={t('search.placeholder')} placeholder={
startDecorator={<SearchRounded />} mode
? t('search.modes.actions.placeholder')
: t('search.placeholder')
}
startDecorator={
mode ? (
<Chip
size='sm'
variant='soft'
color='primary'
onClick={() => {
setMode(null)
setSelectedIndex(0)
}}
>
{t('search.modes.actions.label')}
</Chip>
) : (
<SearchRounded />
)
}
endDecorator={ endDecorator={
isLoading ? ( isLoading ? (
<CircularProgress size='sm' /> <CircularProgress size='sm' />
@@ -468,7 +545,9 @@ const GlobalSearchPalette = ({
/> />
<Typography level='title-md'>{t('search.empty.title')}</Typography> <Typography level='title-md'>{t('search.empty.title')}</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}> <Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{t('search.empty.subtitle')} {mode
? t('search.empty.actionsSubtitle')
: t('search.empty.subtitle')}
</Typography> </Typography>
</Box> </Box>
)} )}
@@ -554,8 +633,13 @@ const GlobalSearchPalette = ({
{t('search.footer.navigate')} {t('search.footer.navigate')}
</Typography> </Typography>
<Typography level='body-xs'> {t('search.footer.open')}</Typography> <Typography level='body-xs'> {t('search.footer.open')}</Typography>
{!mode && (
<Typography level='body-xs'>
{t('search.footer.slashHint')}
</Typography>
)}
<Typography level='body-xs' sx={{ ml: 'auto' }}> <Typography level='body-xs' sx={{ ml: 'auto' }}>
{query.trim() {query.trim() || mode
? t('search.footer.results', { count: matchCount }) ? t('search.footer.results', { count: matchCount })
: t('search.footer.typeToSearch')} : t('search.footer.typeToSearch')}
</Typography> </Typography>